Skip to main content
The agent surface is how you plug arbitrary external work into suchi’s ingest pipeline. Native plugins (Go interfaces, compiled in) are for maintainers; agents are for operators — anything that speaks HTTP can drive a claim/act/complete loop over /api/tasks/. Typical use cases:
  • LLM classifier hosted outside suchi (say, on a workstation with a bigger GPU than your NAS).
  • Human review queue — an operator visits a UI, tags docs, marks each done.
  • Third-party enrichment — resolve invoice numbers against your ERP, hit your VAT-validation service, etc.
  • Rules that need side effects the built-in engine can’t do.

The loop

Six calls in total:
Only jobs whose kind starts with agent: are claimable from outside. Internal chains (post-ingest, post-classify, render) stay off-limits. Operators enqueue agent work either by POST /api/tasks/ themselves or by wiring a producer into postingest that creates agent:<name> jobs on the docs it wants external attention on.

Endpoints

GET /api/tasks/?kind=agent:&state=pending

Returns pending agent-visible jobs. The kind filter is a prefix match — agent: returns everything under that namespace, agent:classify returns only that specific kind. state=pending hides claims currently being worked on.

POST /api/tasks/{id}/claim

Body: {"agent_id": "my-agent-1", "ttl_seconds": 300} Atomic lease. Succeeds only when the job is pending OR running with an expired worker_deadline. Two agents racing on the same job — the second gets 409 Conflict. ttl_seconds is capped at 3600 (1 hour). Default is 300. Response 200:

POST /api/tasks/{id}/complete

Body: {"agent_id": "my-agent-1", "last_note": "classified as invoice"} Marks the job done. agent_id MUST match the current claim — an agent can’t complete someone else’s work. last_note is optional and lands in the job’s last_error column (which the tasks list surfaces for both success and failure — think of it as “last diagnostic”). Returns 204 No Content.

POST /api/tasks/{id}/release

Voluntary early release — call this on graceful shutdown so the job doesn’t wait out its lease. Same agent_id check as complete.

POST /api/tasks/

Body: {"kind": "agent:my-hook", "doc_id": 17, "payload": {...}} Operator-facing enqueue for hand-crafted work. kind must start with agent:. payload is opaque JSON your agent will read. Returns 201 Created with the new job id.

Auth + scopes

Every endpoint uses the standard auth middleware — a browser session cookie OR a Authorization: Token <hex> API token works. Scopes are enforced on the write + agent surfaces: Browser-session principals bypass scope checks — an operator logged into the UI can do anything the role allows. Tokens are held to the scopes column they were issued with. Legacy tokens with the coarse read / write labels (Phase 0/1/2) are treated as wildcards so they keep working; new integrations should adopt the granular set. Issue a dedicated scoped token per agent:
A token missing the required scope gets 403 Forbidden with {"error": "token missing required scope <name>", "code": "insufficient_scope"}.

Reference agent (Python)

A ~30-line agent that classifies every agent:classify job it can grab, adds a tag, and marks it done. Save as agent.py, chmod +x, run in a loop:
Failure modes and why the design is safe against them:

Suggested job-kind conventions

There’s no built-in producer for these kinds today — you enqueue them via POST /api/tasks/ (from a shell, a webhook, or another agent). A future release will wire common producers into postingest (low-confidence LLM output automatically enqueues agent:review, etc.).

Webhook variant (push mode)

For agents that don’t want to poll, register a webhook. On every matching agent:* enqueue, suchi POSTs a signed envelope to the receiver.

POST /api/agent/webhooks

Requires admin:webhooks. Body:
kind_prefix defaults to agent: (all). Must start with agent:. Response 201:
secret is a hex-encoded 32-byte HMAC key returned once — copy it into the receiver’s config immediately. Future GETs never expose it. On the server, secrets are AEAD-sealed with the same .decrypt-key used for PDF passwords.

GET /api/agent/webhooks

Requires admin:webhooks. Lists webhooks scoped to the caller (admins see all). Secret omitted; last_delivery_at, last_status, last_error surface receiver health.

DELETE /api/agent/webhooks/{id}

Requires admin:webhooks. Owner-scoped for non-admins.

Signature verification

Every delivery carries two headers:
  • X-Suchi-Event: agent.task.enqueued
  • X-Suchi-Signature: <hex hmac-sha256> — HMAC of the raw body, keyed with your webhook’s secret.
Receiver MUST:
  1. Read the raw request body (do not re-JSON-encode).
  2. Compute hmac_sha256(secret, body).hex().
  3. Constant-time compare with the header value.
  4. Reject any request without both headers or with a mismatched sig.
  5. Reject if sent_at (in the body) is too far from now — 300s is a safe window.
Example event body:
The event tells the receiver a job exists; the receiver then claims it via the normal poll-loop verbs (POST /api/tasks/917/claim etc.). The webhook is a notification, not a delivery of doc content.

Delivery guarantees

Every delivery goes through the durable outbox as a webhook:deliver job. On failure (non-2xx response or network error) the outbox retries with backoff up to the standard attempts cap; after 5 failed attempts the job lands in state='dead' on /api/tasks/ and last_error on agent_webhooks. A webhook:deliver job whose receiver returns a stale-secret error (AEAD open fails) deactivates the webhook to prevent retry storms.

Not yet

  • Per-agent identity separate from api_tokens — v1 uses whatever token you present; the agent_id in the body is bookkeeping.
  • Bulk claim — claim N tasks in one request. Poll-and-claim-one works for most agent loads today.