API reference
Tiyi exposes one ConnectRPC API. The same contract serves the embedded Admin UI (JSON over HTTP), the tiyi CLI (JSON over HTTP), and the agent stream (gRPC bidi) — so anything the UI can do, a script can do too. 238 RPCs across 23 services, plus a small non-RPC HTTP surface.
Transport
Every procedure is a POST to /{package}.{Service}/{Method}, for example POST /tiyi.v1.SiteService/ListSites. Three wire protocols share the same routes:
| Protocol | Content-Type | Use it for |
|---|---|---|
| Connect (unary) | application/json | curl, scripts, CI — no framing and no generated code required. |
| Connect (streaming) | application/connect+json | server-streaming and bidi RPCs from non-gRPC clients. |
| gRPC / gRPC-Web | application/grpc | generated gRPC clients; the agent stream. |
The API listens on the management address (--addr, default 0.0.0.0:8080) and, in parallel, on a local Unix socket (--admin-socket, default /run/tiyi/admin.sock).
Authentication
Three auth surfaces, picked by deployment context:
| Surface | Mechanism | Used by |
|---|---|---|
| Local admin socket | OS file permissions on admin.sock |
tiyi CLI on the same host as the server. Requests run as a synthetic superadmin, which is why password recovery works with no credentials. |
| HS256 JWT | Authorization: Bearer <jwt> over HTTP |
Admin UI, remote CLI, anything driving the API from off-host. Issued by AuthService.Login, renewed by AuthService.Refresh. |
| Agent stream token | One-use enrollment token → long-lived stream token | Agents on AgentStreamService.Connect. Tokens come from AgentService.IssueEnrollmentToken. |
Login
$ curl -sS http://tiyi:8080/tiyi.v1.AuthService/Login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"..."}'
{
"accessToken": "<jwt>",
"expiresAt": "2026-07-26T21:13:41Z",
"user": {
"id": "<uuid>",
"tenantId": "<uuid>",
"username": "admin",
"roles": ["Administrator"],
"accessCodes": ["site:read", "site:write", "..."]
}
}
Three things that are easy to get wrong:
- The login body is flat —
{"username":…,"password":…}, not nested under a credentials object. - The refresh token is not in the response body. It is set as an
HttpOnly,SameSite=Strictcookie namedtiyi_refreshscoped toPath=/tiyi.v1.AuthService/. Browsers replay it automatically; a script must capture it (curl -c) or pass it explicitly as{"refreshToken":"…"}toRefresh. Refreshrotates the refresh token, and replaying a consumed one is treated as theft: the call failsunauthenticatedand the session is cleared. Always store the newest value.
Default lifetimes are 8 hours for the access token (auth.access_token_ttl) and 7 days for the refresh token (auth.refresh_token_ttl). LDAP and RADIUS, when configured, are tried as fallbacks on the same Login call.
Local admin socket
$ curl -sS --unix-socket /run/tiyi/admin.sock \
http://tiyi.local/tiyi.v1.SystemService/GetSystemSettings \
-H "Content-Type: application/json" -d '{}'
http://tiyi.local is a placeholder — the socket path decides the destination. The socket is 0600 by default; --admin-socket-mode 0660 --admin-socket-group tiyi-admin shares it with a Unix group. Anyone who can open it has full control, so treat that group as root-equivalent.
CLI
$ export TIYI_API=https://tiyi.example.com # omit to use the local socket
$ export TIYI_TOKEN="$TOKEN"
$ tiyi site list
Flags --api, --token, and --admin-socket override the environment per command.
Request conventions
- Responses use camelCase (
primaryHost); requests accept camelCase or the original snake_case (primary_host). - 64-bit integers are JSON strings —
"revision": "2". Send them quoted too. - Enums are prefixed strings —
"RESOURCE_STATUS_ACTIVE","WAF_MODE_BLOCKING". The zero value is always*_UNSPECIFIED. - Timestamps are RFC 3339 UTC —
"2026-07-26T13:15:08.217701748Z". - Mutations wrap their resource:
CreateSitetakes{"site": {…}}, not the fields at top level. Sending them bare returnsinvalid_argument: site is required. - Field masks are comma-joined strings, not arrays:
"updateMask": "name,status". Without a mask the whole message is applied, so a client unaware of a newer field can blank it. - Unknown fields are ignored, not rejected. A misspelled key silently does nothing — verify writes by reading the resource back.
- Every request is tenant-scoped from the token, never from a field in the request.
Revisions
Versioned resources carry a revision that increments on every write. Send the revision you read; if another writer got there first the call fails with failed_precondition: revision conflict. Re-read, re-apply, retry. Omitting revision skips the check.
Routing and per-site policy overrides have their own RPCs (UpdateSiteRouting, UpsertSitePolicyOverride) so that a routing-unaware client cannot erase them with a plain UpdateSite.
Pagination
Bounded lists (sites, users, certs, policies) use offset pages: {"page":{"page":1,"pageSize":50}}, 1-based, default size 50, capped at 200. Log and event queries use cursors: pass the previous nextCursor and stop when hasMore is false. For direct page jumps set offset instead of cursor — capped at 100,000 rows, past which you should narrow the time range.
The 23 services
| Service | Scope |
|---|---|
AuthService | Login, logout, refresh, current user, access codes, password change. |
SystemService | Health, settings, dashboard rollups, declarative apply, CRS/GeoIP/binary releases, upgrade runs. |
MenuService | Permission-filtered navigation for the management UI. |
UserService | User CRUD, role assignment, lock/unlock, password reset. |
RoleService | Role CRUD and the permission catalog. |
SiteService | Site CRUD, enable/disable, compiled-config preview, path routing, per-site policy override. |
UpstreamService | Upstream pool CRUD; deletion refused while in use. |
CertService | Upload, ACME issue/renew, download, DNS provider CRUD. |
PolicyService | WAF policy CRUD, layer updates, version snapshots and rollback, SecLang preview, test lab. |
RuleOverrideService | Per-CRS-rule behaviour and scoped override lifecycle. |
CustomRuleService | Custom SecLang and visual-rule lifecycle, ordering, templates. |
IpListService | Reusable IP data, scoped bindings, CSV import/export, lookup and precedence. |
RateLimitService | Endpoint and client-scope rate-limit resources. |
CrsService | CRS catalog browse, ingest, exclusion-package install and attach. |
AgentService | Agent CRUD, enrollment tokens, install scripts, commands, config bundles, metric samples. |
AgentGroupService | Tag-based and explicit agent targeting with match preview. |
AgentStreamService | The authenticated bidi agent session. |
EvidenceUploadService | Request-evidence upload on its own stream, so large bodies never block control traffic. |
TrustService | Client-IP trust profile (tenant + site overrides), CDN snapshots, explain. |
AlertService | Alert rule and channel CRUD, ack/resolve, silences, notes, channel test send. |
LogService | Security/access/error event queries, retained evidence, exports, log policy, live tail. |
AuditService | Tamper-evident audit query, chain status, and verification. |
AIService | Optional, default-off advisory proposals, copilot analysis, and chat. |
The exhaustive per-RPC reference — every request and response field, with the permission each RPC requires — is generated from the schema and ships in the source tree under docs/api/, alongside the protobuf definitions in proto/tiyi/v1/.
Errors
Errors return a canonical code, a human message, and sometimes typed details. Branch on code (and details[].debug.reason), never on message.
{
"code": "unauthenticated",
"message": "invalid session",
"details": [{ "type": "google.rpc.ErrorInfo",
"debug": { "reason": "TOKEN_INVALID", "domain": "tiyi.io" } }]
}
| Code | HTTP | Typical cause | Retry? |
|---|---|---|---|
invalid_argument | 400 | Input rejected by the validator. | No — fix the request. |
failed_precondition | 400 | Revision conflict, resource in use, or another state guard. | Re-read, then retry. |
unauthenticated | 401 | Missing, malformed, or expired token. | Refresh, retry once. |
permission_denied | 403 | Valid session without the required permission; also ACCOUNT_LOCKED. | No. |
not_found | 404 | Id does not exist or is soft-deleted. | No. |
already_exists | 409 | Uniqueness violated. | No. |
resource_exhausted | 429 | Write-admission queue is full. | Yes — honour Retry-After. |
unimplemented | 501 | Declared in the schema but not wired yet. | No. |
unavailable | 503 | SQLite busy or a dependency is down. | Yes — backoff. |
deadline_exceeded | 504 | Server-side timeout. | Narrow the query. |
internal | 500 | Unhandled server-side failure. | No — report it. |
Known ErrorInfo reasons: BAD_CREDENTIALS, TOKEN_INVALID, ACCOUNT_LOCKED.
Write admission
Mutating RPCs are queued through a write-admission gate so a burst of writers cannot overwhelm the embedded SQLite store. When the queue is full the call returns resource_exhausted with a Retry-After header in seconds. Reads are never queued. Bulk importers should limit write concurrency and respect that header.
RBAC
Each RPC declares the permission it needs — site:read, policy:write, log:export, system:apply, and so on across 57 permissions. The server checks the JWT subject's roles against the permission table; a role holding tiyi:superadmin passes every check. Administrator is the only built-in role and holds everything, so build least-privilege roles with tiyi role or the Roles page, and inspect the live catalog with RoleService.ListPermissions rather than trusting a frozen list.
Seven procedures are reachable without a session: SystemService.Health, AuthService.Login / Refresh / Logout, AgentService.Enroll, AgentStreamService.Connect, and EvidenceUploadService.Upload. The last three are gated by enrollment or stream tokens instead.
Streaming RPCs
Streaming works over gRPC or the Connect streaming protocol. For Connect, set Content-Type: application/connect+json and frame each message with a five-byte envelope: one flag byte, then a big-endian uint32 length. A failed stream still returns HTTP 200 — the status lives in the trailing frame (flag 0x02), so a client that only checks the HTTP code will miss the error.
AgentStreamService.Connect— bidi. The agent session: hello, state reports, events, apply results, observation batches; the server sends welcome, config updates, commands, challenges.EvidenceUploadService.Upload— bidi. Request-evidence bodies, isolated from the control stream.AgentService.StreamAgentEvents— server. Live agent online/offline and metrics.LogService.TailSecurityEvents— server. Live security-event feed for the live-tail page.LogService.StreamRequestEvidenceBody/DownloadRequestEvidenceBody— server. Retained request bodies, chunked.AlertService.StreamAlerts— server. Alert lifecycle events.SystemService.StreamUpgradeRun— server. Per-agent upgrade progress.AIService.StreamAnalysis/StreamChat— server. Incremental copilot output.
Reconnect with backoff. None of these streams replay history, so re-query for anything missed during a gap.
Non-RPC HTTP endpoints
| Endpoint | Listener | Auth | Purpose |
|---|---|---|---|
GET /healthz | both | none | Liveness/readiness probe: 200 healthy, 503 otherwise, JSON body either way. |
GET /download/tiyi | both | none | Serves the running binary so a new agent can bootstrap before it has credentials. |
/api/v1/telemetry/* | both | JWT + telemetry:read | Exact counters: qps, series, topk, apitree, and the API-inventory action route. |
GET /metrics | socket only | socket permissions | Prometheus/OpenMetrics scrape for the observation pipeline. |
GET /debug/* | socket only | socket permissions | Diagnostic counters for logsink, alerts, AI, and licensing. Not a stable contract. |
The management listener also serves the Admin UI, and unknown paths fall back to the SPA with HTTP 200 — so a successful curl against a path that only exists on the socket is returning HTML, not data. Check the Content-Type.
Generating a client
Point your codegen at proto/tiyi/v1 in the source tree. Known-good combinations are connect-go (what the CLI uses), connect-es / @connectrpc/connect-web (what the Admin UI uses), and any standard gRPC client for streaming-heavy work. Because unary Connect is plain JSON over HTTP, a small fetch or requests wrapper is also a perfectly good client.
The package is tiyi.v1; field numbers are never reused and removed fields stay reserved, so older clients keep parsing newer responses. New optional fields and new RPCs arrive in minor releases — treat unknown response fields as ignorable.
API contract
These docs describe the operator-facing API and should agree with the installed binary — file an issue if they don't.