ZQAgent Public API — Integration Guide (English)
Server-to-server API that lets a third-party system (task manager, workflow engine, internal platform) dispatch AI jobs to your agents in bulk, track execution, and query nodes/projects.
- Machine-readable spec:
openapi/zqagent.v1.yaml - Chinese (authoritative) docs:
../README.md - Runnable examples:
examples/api/
This guide is a condensed English reference covering the same surface as the Chinese docs. If they ever disagree, the Chinese docs and the OpenAPI file win.
1. Quick start
1.1 Create an API key
Console → Settings → Integrations (/settings/integrations) → New key:
| Field | Example | Notes |
|---|---|---|
| Name | prod-ticketing | For your own bookkeeping |
| Scopes | jobs:write, jobs:read, resources:read | usage:read for /v1/usage |
| Nodes / projects | empty = unrestricted | Restrict to least privilege |
| Callback URL | https://team.cd-zq.com/api/zqagent/webhook | HTTPS only |
Rate limit (rpm) | 600 | Per key; 0 = unlimited |
| IP allowlist | 203.0.113.10, 198.51.100.0/24 | IPv4 literals or CIDR |
The full secret (zqa_live_…) and the webhook signing secret (whsec_…) are shown once. Store them in your secret manager.
1.2 Discover targets
curl https://hub.example.com/v1/projects \
-H "Authorization: Bearer $ZQA_API_KEY"{
"data": {
"items": [
{ "projectId": "proj_order", "name": "Order service", "nodeId": "node_be",
"online": true, "workspaceRoot": "/srv/repos/backend-order",
"sessionCount": 12, "running": 1, "queued": 3 }
]
},
"requestId": "req_..."
}1.3 Submit jobs
curl -X POST https://hub.example.com/v1/jobs \
-H "Authorization: Bearer $ZQA_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: sprint-42-checkout-1" \
-d '{
"batchId": "sprint-42",
"items": [
{ "kind": "ai", "projectId": "proj_order", "prompt": "Add request-id logging",
"externalId": "TASK-1024", "metadata": { "owner": "team-a" } },
{ "kind": "ai", "projectId": "proj_web", "prompt": "Fix login redirect",
"externalId": "TASK-1025" }
]
}'- Up to 100 items per request. The batch is atomic: all are accepted or none.
- HTTP
202means queued, not executed. Watch for the terminal state. Idempotency-Keyis kept for 24h. Retrying the same key returns the original result.
1.4 Get the result
curl https://hub.example.com/v1/jobs/job_9f2c \
-H "Authorization: Bearer $ZQA_API_KEY"{
"data": {
"id": "job_9f2c",
"status": "done",
"attempt": 1, "maxAttempts": 4,
"durationMs": 184000,
"sessionId": "sess_x1",
"result": {
"summary": "Added request-id middleware to 4 handlers",
"producedFiles": ["src/middleware/requestId.ts", "src/app.ts"],
"usage": { "inputTokens": 21000, "outputTokens": 3400 }
},
"failure": null,
"progress": { "toolCalls": 18, "iterations": 12, "lastActivity": "write_file" }
},
"requestId": "req_..."
}2. Authentication
Authorization: Bearer zqa_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx| Aspect | Behavior |
|---|---|
| Format | zqa_live_ + 32 random chars |
| Storage | Server keeps only a sha256 hash |
| Revoke | Immediate (DELETE /api/hub/agent-api/keys/{id}) |
| Rotate | New key is active immediately; old key can keep working during a grace period |
| Expiry | Optional expiresInDays; expired keys get 401 |
| Rate limit | Default 600 req/min per key; 429 + Retry-After when exceeded |
| IP allowlist | IPv4 / CIDR; mismatch → 403 ip_not_allowed |
Rotation with grace period
curl -X POST https://hub.example.com/api/hub/agent-api/keys/$KEY_ID/rotate \
-H "Authorization: Bearer $CONSOLE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"graceHours": 24}'Both keys work until previousExpiresAt, then the old one returns 401. graceHours accepts 0–720; default 0 (immediate cutoff).
Scopes
| Scope | Grants |
|---|---|
jobs:write | Submit / cancel / retry jobs |
jobs:read | Read jobs and batches |
resources:read | Nodes, projects, overview |
usage:read | /v1/usage |
Missing scope → 403 insufficient_scope.
3. Endpoints
| Method | Path | Purpose |
|---|---|---|
| GET | /v1/overview | Fleet load / queue / latency (?format=compact for LLMs) |
| GET | /v1/projects | Project list (routing table) |
| GET | /v1/nodes | Node list |
| GET | /v1/nodes/{nodeId} | Node detail with per-project load |
| GET | /v1/nodes/{nodeId}/health | Node health snapshot |
| GET | /v1/usage | Usage rollup by day/project/key |
| POST | /v1/jobs | Bulk submit (≤100) |
| GET | /v1/jobs | List with filters |
| GET | /v1/jobs/{id} | Job detail (includes progress) |
| GET | /v1/jobs/{id}/events | SSE event stream |
| POST | /v1/jobs/{id}/cancel | Cancel |
| POST | /v1/jobs/{id}/retry | Retry |
| GET | /v1/batches/{batchId} | Batch summary |
| POST | /v1/batches/{batchId}/cancel | Cancel whole batch |
| POST | /v1/chat/completions | OpenAI-compatible endpoint |
4. Concurrency model
Node (device)
├── Workspace A (project) ← one job at a time
├── Workspace B (project) ← one job at a time
└── Workspace C (project) ← one job at a time
A, B, C run in parallel- Same
projectIdis strictly serial: the agent edits real files; concurrent runs in the same directory would overwrite each other. - Different
projectIdruns in parallel, bounded by node configHUB_JOB_CONCURRENCY(default 1 — set 3–4 if you run several workspaces). - Different nodes are inherently parallel.
Implication: 100 jobs on one project finish sequentially. To get throughput, split the work into multiple workspaces (one projectId each).
5. OpenAI-compatible endpoint
Lets LangChain / Dify / n8n / OpenAI SDK wrappers plug in without changes.
curl -X POST https://hub.example.com/v1/chat/completions \
-H "Authorization: Bearer $ZQA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "zqagent",
"project_id": "proj_order",
"messages": [{ "role": "user", "content": "What is 6 times 7?" }]
}'{
"id": "chatcmpl-job_9f2c",
"object": "chat.completion",
"model": "zqagent",
"choices": [{ "index": 0, "message": { "role": "assistant", "content": "42" }, "finish_reason": "stop" }],
"usage": { "prompt_tokens": 12, "completion_tokens": 3, "total_tokens": 15 },
"zq_job_id": "job_9f2c",
"zq_session_id": "sess_x1"
}project_idis required (top level or insidemetadata) — it selects the workspace.- The last
usermessage becomes the prompt. - Synchronous: waits up to 120s by default (
x-zq-timeoutheader: 5–600s). - On timeout:
504 job_timeoutwithjobId— the job keeps running; poll/v1/jobs/{id}. - The response is the raw OpenAI shape, not wrapped in
{data}.
6. Real-time tracking (SSE)
curl -N https://hub.example.com/v1/jobs/job_9f2c/events \
-H "Authorization: Bearer $ZQA_API_KEY"id: 1
event: job.state
data: {"id":"job_9f2c","status":"running","progress":{"toolCalls":3}}
id: 2
event: job.completed
data: {"id":"job_9f2c","status":"done","result":{"summary":"..."}}| Event | When |
|---|---|
job.state | First frame: current state at connect time |
job.progress | Still running, progress/attempts changed |
job.completed | done → server closes the stream |
job.failed | failed with no retry pending → closes |
job.cancelled | Cancelled → closes |
job.deleted | Job record no longer exists → closes |
: pingevery 15s keeps proxies from cutting idle streams.- Reconnect with
Last-Event-ID(or?since=N) to continue numbering. - Not a token-by-token stream: the Hub has no inbound channel to nodes (nodes connect outbound). Use
sessionIdin the console for full traces.
7. Webhooks
Subscribe per job (callbackUrl in the item) or per key (default callback URL).
| Event | Fires when |
|---|---|
job.completed | Job reached done |
job.failed | Job reached failed and will not retry |
job.cancelled | Job was cancelled |
batch.completed | All jobs in a batch reached a terminal state |
Headers:
X-ZQ-Event: job.completed
X-ZQ-Delivery: dlv_a1b2
X-ZQ-Timestamp: 1762673523
X-ZQ-Signature: v1=<hex hmac>Verifying the signature
v1 = HMAC-SHA256(webhookSecret, timestamp + "." + rawBody).
Three things that must be right:
- Use the raw body bytes, not a re-serialized JSON object (key order changes).
- Compare in constant time (
crypto.timingSafeEqual/hmac.compare_digest). - Check the timestamp window (300s) to stop replays.
import crypto from "node:crypto";
export function verify(rawBody: Buffer, headers: Record<string, string>, secret: string): boolean {
const ts = headers["x-zq-timestamp"];
const got = headers["x-zq-signature"];
if (!ts || !got) return false;
const expected = "v1=" + crypto.createHmac("sha256", secret)
.update(`${ts}.`).update(rawBody).digest("hex");
const a = Buffer.from(got), b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return false;
return Math.abs(Date.now() / 1000 - Number(ts)) < 300;
}import hashlib, hmac, time
def verify(raw_body: bytes, headers: dict, secret: str) -> bool:
ts = headers.get("x-zq-timestamp", "")
got = headers.get("x-zq-signature", "")
if not ts or not got:
return False
expected = "v1=" + hmac.new(
secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(got, expected):
return False
return abs(time.time() - int(ts)) < 300Retries and idempotency
Backoff: 10s → 30s → 2m → 10m → 1h. 2xx stops retrying; anything else is retried. Dedupe by X-ZQ-Delivery (the same event may be delivered more than once, and a job may go failed (retrying) → done; check isFinal before treating a failure as final).
Delivery log and manual redelivery
GET /api/hub/agent-api/deliveries?limit=50
POST /api/hub/agent-api/deliveries/{deliveryId}/redeliverstatus is pending / delivered / failed; lastError tells you whether it was DNS, TLS or a 5xx on your side. Redelivery uses the original payload (so dedupe by deliveryId still works).
8. Errors
{
"error": { "code": "node_offline", "message": "Node node_be is offline", "retriable": false },
"requestId": "req_..."
}Branch on code, not message. requestId also appears as X-Request-Id.
| HTTP | code | Meaning | Retry? |
|---|---|---|---|
| 401 | missing_api_key | No Authorization header | ❌ |
| 401 | invalid_api_key | Unknown / revoked / expired | ❌ |
| 403 | insufficient_scope | Missing scope | ❌ |
| 403 | project_not_allowed / node_not_allowed | Key is restricted | ❌ |
| 403 | ip_not_allowed | Source IP not in the key's allowlist | ❌ |
| 400 | invalid_request | Validation failure (message points to item N) | ❌ |
| 400 | project_mismatch | Project is not on the specified node | ❌ |
| 400 | ambiguous_project | projectId exists on several nodes; pass nodeId | ❌ |
| 404 | node_not_found / project_not_found / job_not_found | Not found or not yours | ❌ |
| 409 | node_offline | Target node offline (no failover by design) | ⏳ wait |
| 409 | job_finished / job_not_retryable | Invalid state transition | ❌ |
| 429 | rate_limited | Key quota exceeded | ✅ honor Retry-After |
| 500 | internal_error | Server error | ✅ 1–2 times |
Failure types (failure.type)
| Type | Meaning | Auto-retry |
|---|---|---|
node_lost | Node lost (lease expired) | ✅ |
llm_error | Model-side error; only when retriable | ✅ conditional |
engine_error | Engine/framework error | ❌ |
timeout | Turn timeout (25 min cap) | ❌ |
max_iterations | Iteration cap hit | ❌ |
project_not_found | Project missing on the node | ❌ |
script_failed | Non-zero exit code | ❌ |
cancelled | Cancelled | ❌ |
timeout and max_iterations are not retried: same input, same cap → same result, just burned quota. Split the task or adjust the prompt.
9. Security boundaries
- No browser/CORS access: keys must stay on servers.
- Callback URLs must be HTTPS, must resolve to a public address (no loopback / RFC1918), and must be in the allowlist when one is configured.
- Tenant isolation: another tenant's
jobIdreturns404, never403— no existence probing. - For production keys, set both an IP allowlist and a per-minute limit.
10. Versioning
- Prefix
/v1; fields are additive only. Breaking changes go to/v2. - Deprecations announce via the
Deprecationresponse header and stay for ≥12 months. - Every response carries
X-Request-Id; include it when reporting issues.
11. Runnable examples
| File | What it does |
|---|---|
examples/api/curl.sh | Full smoke test: overview → projects → submit → poll → usage |
examples/api/python/zqagent_client.py | Zero-dependency Python client + wait() helper |
examples/api/node/client.ts | Zero-dependency Node client + webhook verifier |
Postman
Import openapi/zqagent.v1.yaml directly (Postman 10+ supports OpenAPI 3 import). Set two collection variables: baseUrl and apiKey, and use Authorization: Bearer .
