Skip to content

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.

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:

FieldExampleNotes
Nameprod-ticketingFor your own bookkeeping
Scopesjobs:write, jobs:read, resources:readusage:read for /v1/usage
Nodes / projectsempty = unrestrictedRestrict to least privilege
Callback URLhttps://team.cd-zq.com/api/zqagent/webhookHTTPS only
Rate limit (rpm)600Per key; 0 = unlimited
IP allowlist203.0.113.10, 198.51.100.0/24IPv4 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 ​

bash
curl https://hub.example.com/v1/projects \
  -H "Authorization: Bearer $ZQA_API_KEY"
json
{
  "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 ​

bash
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 202 means queued, not executed. Watch for the terminal state.
  • Idempotency-Key is kept for 24h. Retrying the same key returns the original result.

1.4 Get the result ​

bash
curl https://hub.example.com/v1/jobs/job_9f2c \
  -H "Authorization: Bearer $ZQA_API_KEY"
json
{
  "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 ​

http
Authorization: Bearer zqa_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
AspectBehavior
Formatzqa_live_ + 32 random chars
StorageServer keeps only a sha256 hash
RevokeImmediate (DELETE /api/hub/agent-api/keys/{id})
RotateNew key is active immediately; old key can keep working during a grace period
ExpiryOptional expiresInDays; expired keys get 401
Rate limitDefault 600 req/min per key; 429 + Retry-After when exceeded
IP allowlistIPv4 / CIDR; mismatch → 403 ip_not_allowed

Rotation with grace period ​

bash
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 ​

ScopeGrants
jobs:writeSubmit / cancel / retry jobs
jobs:readRead jobs and batches
resources:readNodes, projects, overview
usage:read/v1/usage

Missing scope → 403 insufficient_scope.

3. Endpoints ​

MethodPathPurpose
GET/v1/overviewFleet load / queue / latency (?format=compact for LLMs)
GET/v1/projectsProject list (routing table)
GET/v1/nodesNode list
GET/v1/nodes/{nodeId}Node detail with per-project load
GET/v1/nodes/{nodeId}/healthNode health snapshot
GET/v1/usageUsage rollup by day/project/key
POST/v1/jobsBulk submit (≤100)
GET/v1/jobsList with filters
GET/v1/jobs/{id}Job detail (includes progress)
GET/v1/jobs/{id}/eventsSSE event stream
POST/v1/jobs/{id}/cancelCancel
POST/v1/jobs/{id}/retryRetry
GET/v1/batches/{batchId}Batch summary
POST/v1/batches/{batchId}/cancelCancel whole batch
POST/v1/chat/completionsOpenAI-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 projectId is strictly serial: the agent edits real files; concurrent runs in the same directory would overwrite each other.
  • Different projectId runs in parallel, bounded by node config HUB_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.

bash
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?" }]
  }'
json
{
  "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_id is required (top level or inside metadata) — it selects the workspace.
  • The last user message becomes the prompt.
  • Synchronous: waits up to 120s by default (x-zq-timeout header: 5–600s).
  • On timeout: 504 job_timeout with jobId — the job keeps running; poll /v1/jobs/{id}.
  • The response is the raw OpenAI shape, not wrapped in {data}.

6. Real-time tracking (SSE) ​

bash
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":"..."}}
EventWhen
job.stateFirst frame: current state at connect time
job.progressStill running, progress/attempts changed
job.completeddone → server closes the stream
job.failedfailed with no retry pending → closes
job.cancelledCancelled → closes
job.deletedJob record no longer exists → closes
  • : ping every 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 sessionId in the console for full traces.

7. Webhooks ​

Subscribe per job (callbackUrl in the item) or per key (default callback URL).

EventFires when
job.completedJob reached done
job.failedJob reached failed and will not retry
job.cancelledJob was cancelled
batch.completedAll jobs in a batch reached a terminal state

Headers:

http
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:

  1. Use the raw body bytes, not a re-serialized JSON object (key order changes).
  2. Compare in constant time (crypto.timingSafeEqual / hmac.compare_digest).
  3. Check the timestamp window (300s) to stop replays.
ts
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;
}
python
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)) < 300

Retries 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 ​

http
GET  /api/hub/agent-api/deliveries?limit=50
POST /api/hub/agent-api/deliveries/{deliveryId}/redeliver

status 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 ​

json
{
  "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.

HTTPcodeMeaningRetry?
401missing_api_keyNo Authorization header❌
401invalid_api_keyUnknown / revoked / expired❌
403insufficient_scopeMissing scope❌
403project_not_allowed / node_not_allowedKey is restricted❌
403ip_not_allowedSource IP not in the key's allowlist❌
400invalid_requestValidation failure (message points to item N)❌
400project_mismatchProject is not on the specified node❌
400ambiguous_projectprojectId exists on several nodes; pass nodeId❌
404node_not_found / project_not_found / job_not_foundNot found or not yours❌
409node_offlineTarget node offline (no failover by design)⏳ wait
409job_finished / job_not_retryableInvalid state transition❌
429rate_limitedKey quota exceeded✅ honor Retry-After
500internal_errorServer error✅ 1–2 times

Failure types (failure.type) ​

TypeMeaningAuto-retry
node_lostNode lost (lease expired)✅
llm_errorModel-side error; only when retriable✅ conditional
engine_errorEngine/framework error❌
timeoutTurn timeout (25 min cap)❌
max_iterationsIteration cap hit❌
project_not_foundProject missing on the node❌
script_failedNon-zero exit code❌
cancelledCancelled❌

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 jobId returns 404, never 403 — 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 Deprecation response header and stay for ≥12 months.
  • Every response carries X-Request-Id; include it when reporting issues.

11. Runnable examples ​

FileWhat it does
examples/api/curl.shFull smoke test: overview → projects → submit → poll → usage
examples/api/python/zqagent_client.pyZero-dependency Python client + wait() helper
examples/api/node/client.tsZero-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 .