Snag for developers

Snag agent API

The HTTP API Snag Control calls to reach an agent on your own server. Implement it and Snag can ask your agents questions, hand them jobs, follow those jobs, and take approvals by voice, on the phone or in the car.

Snag hardcodes no agent. Everything Snag knows about an agent comes from your server's answer to GET agents.

Snag Control is a Pro feature. See Plans.

Connect Snag to your server

  1. In Snag, open the Control tab and add an agent.
  2. Pick Add an agent server ("Your own agent server, using the Snag agent API").
  3. Enter the Base URL (for example https://agent.example.com/snag/v1/) and the Key, then tap Connect.
  4. Snag calls GET agents. If agents come back, it says "Connected." and names them. If nothing answers, it says "Saved." and tries again each time Snag opens.

The key is stored in this iPhone's Keychain only.

An agent API server can also be declared in a repo's .snag/agents.yaml with connection: agent_api. The file gives the address and key; the agents and their names come from GET agents. See Commands in a repo.

Transport and auth

ItemValue
Base URLAny path. Snag appends the endpoint path to it ({base}/agents, {base}/tasks/{id})
Schemehttps. Plain http only to a local network address (iOS allows local networking only)
AuthAuthorization: Bearer <token> on every request. With no key saved, Snag sends nothing and treats the agent as signed out
Headers on every requestAccept: application/json, User-Agent: Snag
Headers on every POSTContent-Type: application/json, Idempotency-Key: <UUID> (new for each request)
Request timeout60 s; POST ask waits its timeout and 15 s more
DatesISO 8601 without fractional seconds, e.g. 2026-10-16T08:30:00Z
PushNone. Snag polls (see Polling)

Endpoints

MethodPathCalled when
GETagentsSnag starts, and whenever Control settings change
POSTaskA question for an agent ("Coach, how did my run go?")
POSTtasksA job for an agent ("Ask ops to check the backups")
GETtasks/{id}Polling a task still in flight
GETattentionPolling for pending and held approvals, only when one of your agents lists approve in can
POSTapprovals/{id}The user approved, held or denied an approval
POSTdevicesSnag registers or replaces this phone's approval keys, only when GET agents has approval_auth
DELETEdevices/{device_id}The user removes this server from Snag, or turns approval signing off
POSTtasks/{id}/parkReserved. This version parks items on the phone and does not call it

GET agents

The agents this server serves.

Request:

GET /snag/v1/agents
Authorization: Bearer <token>
Accept: application/json

Response 200:

{
  "agents": [
    {
      "id": "coach",
      "name": "Coach",
      "spoken_name": "coach",
      "aliases": ["my coach"],
      "can": ["ask", "dispatch", "approve"],
      "sensitivity": "private",
      "approval_classes": ["routine", "consequential"],
      "ask_timeout": 20
    }
  ]
}
FieldRequiredMeaning
idYesYour agent id. Sent back as agent_id in every request
spoken_nameNoThe name the user says. Falls back to name, then id
nameNoDisplay name, used only when spoken_name is missing
aliasesNoOther names the user may say ("my coach")
canNoAny of ask, dispatch, approve. Unknown values are dropped. Empty: the agent can take nothing
sensitivityNonormal, personal or private (see Sensitivity). Missing: normal. Unknown: private
approval_classesNoThe approval classes the agent may propose. Unknown values are dropped
ask_timeoutNoSeconds an ask waits for its answer before it becomes In flight. Default 30, 1 to 300

Response, top level, optional:

{"agents": [...], "approval_auth": 1}
FieldRequiredMeaning
approval_authNo1: this server takes approval assertions (see Approval assertions). Snag then calls POST devices and signs every decision. Missing: Snag signs nothing

If GET agents fails, Snag keeps the agents it last had from this server.

The user can add aliases and set a stricter or looser sensitivity per agent on the phone. Those changes stay on the phone.

POST devices

Registers this phone's two approval keys. Snag calls it after GET agents when the server has approval_auth and this phone has not registered with it, and again whenever a key changes.

Request:

{
  "device_id": "9C2E4B7A-1F3D-4E8B-A6C5-2D7F0B1E3A94",
  "keys": [
    {"use": "device", "kid": "8lKcdLsXhRTrhibzraz9XjX6sd-DFoHo0UWtm6300oI",
     "jwk": {"kty": "EC", "crv": "P-256", "x": "p_0ysIzG857nbAp2bgC5R63NSEvyayAHvQ1ZzBh76z4", "y": "IaEBtUXOV6YsUKsVZtKB-85K0CzqW4G4TqICjz7fZHc"}},
    {"use": "face_id", "kid": "0rwAOmnJ0DVwpWiM5wpK0xFjwDTFdRAoyQVEX-1nRyY",
     "jwk": {"kty": "EC", "crv": "P-256", "x": "qS8bof3lt0_x0fQKiHQMu81aKKkm14DMbPR-r5YRJAA", "y": "YswsrB8RDO7tbyA8hgD9W4zKd7R9WSQlc-nTAK3X6GQ"}}
  ],
  "signed_at": "2026-10-17T09:00:00Z",
  "sig": "MEYCIQCJAS0QEV1RenOefIFPLwG2-1OAJCdGcbgBpSMbV2KdlAIhAKa1R5zT4itxro118iccZUY8rmrdYTHpv46MsytpKTqf"
}
FieldRequiredMeaning
device_idYesA UUID Snag makes per server, so two servers cannot link the same phone
keysYesOne device key, and a face_id key when Face ID is set up on the phone
keys[].useYesdevice (signs every method but face_id) or face_id (signs face_id only)
keys[].kidYesThe key's RFC 7638 JWK thumbprint: SHA-256 of {"crv":"P-256","kty":"EC","x":"…","y":"…"}, base64url
keys[].jwkYesThe public key, kty: EC, crv: P-256
signed_atYesWhen Snag signed it
sigYesSignature over the registration string below, by the device key already registered for this device_id, or by the new device key on first registration

Registration string, UTF-8, lines joined with a single LF, no trailing LF (the face_id line is empty when there is no face_id key):

snag-device-v1
<device_id>
<device kid>
<face_id kid>
<signed_at>

Response 200:

{"device": {"device_id": "9C2E4B7A-1F3D-4E8B-A6C5-2D7F0B1E3A94", "status": "active"}}
statusMeaning
activeThe keys are in use from now
pendingStored but not trusted until the server's operator says so: a second phone on a token, or a known phone with a new face_id key. Snag shows "Waiting for the server to trust this phone." and its signatures count as unsigned until a later POST devices returns active

Check kid against jwk (compute the thumbprint from x and y in canonical base64url, so one key has one kid), check sig, check that the two keys differ, and that signed_at is within 5 minutes of your clock. Answer 400 when any of these fails. An existing device_id with a sig not made by its current device key gets 409 {"spoken": "That phone is already set up."}.

Replacing and revoking:

  • A new face_id key (after a Face ID enrolment change) is a POST devices with the same device_id, signed by the current device key. The old face_id key stops working at once. Since only the device key signs it, anyone with the phone's passcode could have enrolled that face, so answer pending and trust the phone again only when your operator says so. Log it. The token's requirements stay in force meanwhile.
  • A lost device key cannot be replaced. Snag makes a new device_id and registers again, as a new phone.
  • DELETE devices/{device_id} revokes both keys (response 204 or 200). Snag sends it when the user removes the server. Revoking the bearer token revokes every device registered under it.
  • A server may revoke a device on its own. Decisions signed by a revoked, pending or unknown key count as unsigned.
  • A device key never changes: a replace that brings a different device key is refused (409).
  • The claim is sticky. Once a token has had an active phone it stays claimed, even after that phone is revoked or sent back to pending: requires still applies, and the next phone is pending until your operator trusts it. Otherwise a stolen token could revoke the real phone and register its own as the first.

Trust on first use is per token: whoever registers first under a token is active, with no check that it is the owner's phone. Register the owner's phone before the token is used anywhere else.

Keep the device store across restarts. A store that comes back empty (a lost file, a volume that did not mount) reopens first use for every token, so refuse to start when a store that existed is missing, rather than starting empty.

POST ask

A quick question. Answer in the response if you can; otherwise return a task and Snag polls it.

Request:

POST /snag/v1/ask
Authorization: Bearer <token>
Content-Type: application/json
Idempotency-Key: <uuid>

{
  "agent_id": "coach",
  "utterance": "how did my run go?",
  "context": {"driving": true, "source": "carplay", "locale": "en_GB", "session_id": "6A1F0E2C-5B7D-4C93-8E21-0F4B9D3A7C55"},
  "timeout": 20
}

timeout is the agent's ask_timeout in whole seconds, at least 1 and at most 300. utterance is what the user said after the agent's name.

Answer within timeout seconds of receiving the request: 200 if you have the answer, otherwise 202 with a task (sooner is fine). Snag waits timeout + 15 s. A response later than that is lost: the ask is parked as unreachable, and the user sends it again.

An agent_id this server does not serve gets 422 with {"spoken": "No agent by that name here."}, on POST ask and POST tasks alike. Snag says the reason, parks the item and reads GET agents again. 404 still means not set up, usually a wrong base URL.

Response with the answer (status 200):

{"answer": {"spoken": "Good run. 5 k in 27 minutes 40, your best this month.", "detail_url": "https://agent.example.com/runs/123", "sensitivity": "private"}}

Response when it will take longer (status 202):

{"task": {"id": "t_123", "state": "working"}}

Snag reads the body, not the status: answer is spoken, task goes In flight ("Sent. I'll check back."). A body with neither is a bad response.

Answer fieldRequiredMeaning
spokenYesThe text to say. Write for the ear: plain sentences, 25 words or fewer
detail or detail_urlNoLonger text or a link. Kept with the answer for the phone
sensitivityNoThis answer's own level. The stricter of this and the agent's level applies

POST tasks

Hand over a job. Snag says "Sent to coach." and follows the task by polling.

Request:

{
  "agent_id": "coach",
  "utterance": "plan my training week",
  "mode": "now",
  "context": {"driving": false, "source": "phone", "locale": "en_GB", "session_id": "6A1F0E2C-5B7D-4C93-8E21-0F4B9D3A7C55"}
}

Response (status 202, any 2xx works):

{"task": {"id": "t_124", "state": "submitted", "speech": {"ack": "Sent."}}}

task.id is required. speech.ack is accepted and not spoken in this version.

GET tasks/{id}

Where a task has got to.

Response:

{
  "task": {
    "id": "t_124",
    "agent_id": "coach",
    "state": "completed",
    "speech": {"status": "Done.", "digest": ["Three runs and a rest day.", "Long run on Sunday."]},
    "sensitivity": "personal"
  }
}
stateWhat Snag does
completedThe answer is speech.digest joined with spaces; if empty, speech.status; if neither, "Done.". It waits in Waiting on you to be heard
needs_approvalReads task.approval (see Approvals) and puts it in Waiting on you. Without an approval object the task stays in flight
failed, canceledMarked failed, with speech.status as the reason
parkedMoved to Parked
anything else (submitted, working)Still in flight

While a task is submitted or working, speech may say what it is doing now:

{
  "task": {
    "id": "t_124",
    "agent_id": "coach",
    "state": "working",
    "speech": {"step": "running the tests", "step_at": "2026-10-17T09:14:05Z"}
  }
}
Speech fieldRequiredMeaning
stepNoWhat the task is doing now, on submitted or working. A short present participle, 6 words at most, lower case, no full stop, no tool names or file paths: running the tests, reading the logs, drafting the reply
step_atNoWhen the task reached this step. Change it only when step changes

What Snag does with a step:

  • Asked how an agent is getting on, Snag says "It's " + step: "It's running the tests." With no step: "It's working on it. No step yet."
  • The In flight row shows the step with its first letter capitalised: "Running the tests". When step_at is more than 2 minutes old it adds the age: "Running the tests (2 min ago)", in whole minutes, then whole hours from 60 minutes ("(2 hr ago)"). Past 24 hours it reads "(yesterday)" instead of a number: "Running the tests (yesterday)".
  • Snag keeps the first 6 words and drops a step that contains /, \ or a backtick. A step on a completed, failed or parked task is ignored.

Map tool calls to plain phrases in your server ("reading the code", "running a command"); never pass a tool name or a path through.

When the task is gone:

StatusWhat Snag does
410The server has dropped the task. Marked failed at once: "Coach lost that one."
404Marked failed the same way on the second 404 in a row for that task. One 404 changes nothing, in case it was a routing slip

A server that forgets finished tasks after a while should answer 410 for ids it issued, and 404 only for ids it never issued.

GET attention

Approvals waiting for a decision. Called only when at least one agent on this server has approve in can.

Response:

{
  "items": [
    {
      "type": "approval",
      "agent_id": "coach",
      "approval": {
        "id": "ap_77",
        "task_id": "t_124",
        "action": "calendar.book",
        "target": "Thursday 07:00",
        "class": "consequential",
        "spoken_prompt": "Coach wants to book a 7 am run on Thursday.",
        "confirm_phrase": "book it",
        "car_allowed": true,
        "expires_at": "2026-10-16T18:00:00Z",
        "state": "pending"
      }
    }
  ]
}

items lists every approval that is pending or held, including those on tasks Snag is polling. Snag matches by approval id: an id it already holds is not added again, and its local state is kept (a held approval stays held, and a task's approval stays with its task). An approval it does not hold is added in the state sent, so a held approval shows as held on a second phone or after a reinstall, and is not asked again until the user opens it. Items whose type is not approval are ignored. Without agent_id, the approval is filed under the server's first agent.

POST approvals/{id}

The user's decision. {id} is the approval's id.

Request (auth only when the server has approval_auth and this phone is registered):

{
  "decision": "approve",
  "heard_phrase": "approve",
  "context": {"driving": false, "source": "phone", "locale": "en_GB", "session_id": "6A1F0E2C-5B7D-4C93-8E21-0F4B9D3A7C55"},
  "auth": {
    "v": 1,
    "method": "face_id",
    "device_id": "9C2E4B7A-1F3D-4E8B-A6C5-2D7F0B1E3A94",
    "kid": "0rwAOmnJ0DVwpWiM5wpK0xFjwDTFdRAoyQVEX-1nRyY",
    "signed_at": "2026-10-17T09:12:30Z",
    "sig": "MEUCIQDepbGkWjG2TAwI4nRt4FO8pH61UM_ASLlPp2WaDoba9gIgL_2II96Y36pbK2xXLYTHFX7Iou4jWcBYja__SP7Tu0o"
  }
}
FieldMeaning
decisionapprove, hold or deny
heard_phraseWhat Snag heard by voice (voice, phrase). For a tap, Face ID or the code word it is the decision word itself (approve, hold, deny). The code word is never sent
contextWhere the decision was made. driving: true means the car
authOptional. How the person decided, signed by this phone (see Approval assertions)

Response 200:

{"spoken": "Booked. Thursday 7 am."}

On approve, Snag says "Approved." and then spoken (25 words at most). With no spoken, it says "Approved. I'll tell you when it's done.".

To refuse, answer 409 (or another 4xx other than 401, 403, 404 and 408) with a short reason:

{"spoken": "That one waits until you've stopped."}

Snag says the reason (8 words at most), approves nothing and parks the item for the phone. Your server should re-check every decision at this moment: Snag's checks are on top of yours, not instead of them.

A decision on an approval that is no longer pending or held (already approved, denied or expired) gets 409 with the reason and the state:

{"spoken": "That one is already decided.", "state": "approved"}

Snag says the reason, sets the item to that state and moves it to Done, not Parked: there is nothing left to decide. This applies to hold and deny as well as approve.

A refusal because of requires carries the requirement, and the current nonce when the server uses them (new after an attempt whose signature verified, unchanged otherwise):

{"spoken": "That one needs Face ID on the phone.", "requires": "face_id", "auth_nonce": "<the current nonce>"}

With requires in a 409, Snag speaks its own line for it (see Approval assertions) instead of spoken, records the requirement on the approval and parks it for the phone. spoken is for apps without this change.

Status codes

The same mapping applies to every endpoint.

StatusSnag treats it asWhat the user hears
2xxSuccess; the body is readThe answer or acknowledgement
401, 403Signed out"Coach needs signing in. Parked."
404Not set up"Couldn't reach coach. Parked."
410Gone (GET tasks/{id} only)Nothing. The task is marked failed: "Coach lost that one."
422Refused (POST ask, POST tasks: unknown agent)Your spoken reason. Snag reads GET agents again
408, 429, 5xx, no connectionUnreachable"Couldn't reach coach. Parked."
Any other 4xxRefusedYour spoken reason, or "Couldn't reach coach. Parked." without one
2xx with a body Snag cannot readBad response"Couldn't reach coach. Parked."

A failed ask, dispatch or decision is parked for the phone and never retried in the car. Polling errors are silent: the item stays as it was and the next poll tries again. The exceptions are 410 and a second 404 in a row on GET tasks/{id} (see GET tasks/{id}).

Polling

  • Snag polls while it is open and Control is in use: on opening, then every 60 s, and when the user asks ("anything need me", "what did coach say").
  • Each poll calls GET tasks/{id} for every task in flight and GET attention for servers with an approve agent.
  • Nothing is polled while Snag is in the background. To prompt a refresh, post to an ntfy topic the user has set in Settings > Advanced > ntfy; Snag checks it each time it opens.

Idempotency

Every POST carries a fresh Idempotency-Key (a UUID). Snag does not retry a failed POST with the same key: a failure is parked and the user sends it again from the phone, which makes a new request. Use the key to drop duplicate deliveries from proxies or network retries.

Context

Sent with every POST.

FieldValues
drivingtrue from CarPlay or when audio goes to the car
sourcecarplay, siri (a voice intent) or phone
localeThe phone's locale, e.g. en_GB
session_idA UUID per exchange

Sensitivity

Per agent, and optionally per answer. The stricter applies.

LevelShown in Snag asSpoken
normalEverydayFreely
personalPersonalOnly after a yes to "Say it out loud?" (this version asks everywhere, as for Private)
privatePrivateOnly after a yes to "Say it out loud?". Anything but a clear yes parks it unspoken

An answer asked from the phone is shown on screen, never spoken.

Approvals

The class is set by your server, never by Snag.

classOn the phone and in the carWhile driving
routineSnag reads spoken_prompt, then "Approve or hold?". Only "approve" approvesAllowed
consequentialSnag reads spoken_prompt, then "Say book it to approve." The user must repeat confirm_phraseAllowed when car_allowed is true
destructiveOn the phone only, through Face ID. By voice it parks for the phone (by default: "That one needs Face ID. Approve it on the phone.")Never: parked; your server is not called
unknownTreated as destructiveNever
Approval fieldRequiredMeaning
idYesUsed in POST approvals/{id}
actionYesA stable machine id for the action type, e.g. calendar.book. The user's per-action rules key on it, so keep it stable
classYesroutine, consequential or destructive
spoken_promptYesOne line read back before the question. 20 words or fewer
confirm_phraseConsequential and destructiveWhat the user repeats. Without one, the approval needs the phone: "That one needs the phone. Parked."
targetNoWhat the action applies to
task_idNoThe task it belongs to
car_allowedNoDefault true. Always false for destructive, whatever is sent
expires_atNoAfter this, Snag says "That one has expired. Held." and sends hold
stateNopending, approved, held, denied, expired or parked. Default pending
requiresNoface_id or code_word: what this approval needs on top of its class (see Approval assertions). Snag asks for it up front
auth_nonceNoA random value the server sets per approval and changes after each approve attempt whose signature verified and after a successful approve; an unsigned, malformed or unverifiable attempt leaves it. Signed into the assertion. Base64url, 16 bytes or more

Rules that always apply:

  • Silence or a mishear is a hold, never an approval.
  • "deny", "reject" or "refuse" sends deny.
  • "park it" parks the item on the phone and sends nothing.
  • The user's own rules per agent and action type can ask for more (a code word, Face ID, passenger mode) or block the action from Snag altogether. New action types start from defaults based on the class: routine and consequential by voice in the car, destructive parked for a screen and Face ID on the phone.
  • Per server, the user sets which classes need Face ID on the phone even when your server does not ask ("Needs Face ID for": none, destructive, destructive and consequential, everything; default destructive). Those approvals are never decided by voice or in the car, and on the phone only Face ID approves them, not the passcode. Where your requires asks for more, yours applies.

Approval assertions

An optional auth object on POST approvals/{id} says how the person decided, signed by a key held in this iPhone's Secure Enclave. It lets your server refuse, for example, a destructive approve made without Face ID, even from a caller holding the bearer token.

Keys. Snag makes two P-256 keys per server in the Secure Enclave and registers their public halves with POST devices:

KeyMade withSigns
deviceUsable while the phone isn't locked, this device onlyvoice, phrase, screen, code_word, and every hold and deny
face_idBiometric access control (current enrolment only). A passcode cannot use it, and a new face or finger enrolled on the phone invalidates itface_id only

A face_id claim counts only when signed by the device's face_id key. A face_id claim signed by the device key is invalid.

Methods.

methodThe person
voiceSaid "approve" to a routine approval
phraseRepeated the confirm_phrase
screenTapped on the phone or the car screen, or used the phone's passcode
face_idApproved on the phone with Face ID
code_wordSaid their code word in the car, checked on the phone

What is signed. UTF-8, lines joined with a single LF, no trailing LF:

snag-approval-v1
<approval id>
<decision>
<method>
<heard_sha256>
<signed_at>
<auth_nonce>
<device_id>
<kid>
  • approval id: the approval's id as received, not URL-encoded.
  • heard_sha256: SHA-256 of heard_phrase exactly as sent (UTF-8), base64url without padding; an empty line when there is no heard_phrase.
  • auth_nonce: the approval's latest auth_nonce; an empty line when it has none.
  • A field that contains an LF makes the assertion invalid.

The signature is ECDSA P-256 with SHA-256, DER-encoded, base64url without padding (SecKeyCreateSignature with .ecdsaSignatureMessageX962SHA256; Node crypto.verify('sha256', data, {key, dsaEncoding: 'der'}, sig)).

Test vector (the request above): heard_phrase approve hashes to dOIWgOrHOFykCMsBh4Rl_Wk7N-WOsMDDJmOi2PFdgTY; with approval id ops.ap_42 and auth_nonce q8Zr1kV0tYw3nB6xJm2pLA, the sig shown verifies against the face_id JWK in the POST devices example.

Checking an assertion. In this order; the first failure decides:

  1. v is 1, and device_id and kid name an active key registered under this bearer token.
  2. The key's use fits the method (face_id key for face_id, device key for the rest).
  3. sig verifies over the string rebuilt from the request and your own auth_nonce.
  4. signed_at is within 5 minutes of your clock.

A request that fails any check is treated as unsigned. It is refused (409 {"spoken": "That approval didn't check out."}) when the approval needs an assertion.

Nonces. Replace an approval's auth_nonce after an approve whose signature verified (even when a later check refuses it, such as a stale signed_at) and after a successful approve, so a signed request cannot be sent twice. Leave it as it is after an unsigned approve, a malformed auth or a signature that does not verify: otherwise a caller with only the bearer token could keep breaking the phone's signed approve. Every 409 on an approve carries the current one, so Snag need not poll again.

Requirements. Your server decides which approvals need what and says so in requires:

requiresAccepted methods
face_idface_id
code_wordcode_word or face_id

What Snag does with requires:

Whereface_idcode_word
Approval card"Needs Face ID""Needs your code word"
PhoneFace ID, whatever the user's own ruleFace ID
Car, by voice or on the car screen"That one needs Face ID. Approve it on the phone." Parked; your server is not calledAsks for the code word. With no code word set on the phone: "That one needs your code word." and parked

"That one needs Face ID. Approve it on the phone." is 10 words, over the 8-word cap for refusals; Snag says it whole.

A valid assertion with method screen, face_id or code_word stands in for the confirm phrase: heard_phrase is then approve. Your other checks (class, car_allowed, driving, expiry) still apply.

Hold and deny never need an assertion. Snag signs them anyway when it can.

When to enforce. Until a phone is registered under a token, a server cannot tell a phone without this change from a caller that left auth out. Apply requires to every approve for a token once it has had an active device (the claim stays after a revoke), and before that, decide as you did without it.

A shared demo token. A token that is public by design, used by many phones, cannot use trust on first use: the first phone would hold the Face ID approvals for everyone. Such a server may trust every phone at once instead, keep its devices in memory only, and drop the oldest past a cap. Do that only for agents on made-up data; never for real agents.

What it proves. That a registered phone, and for face_id the face enrolled on it, made the decision: a bearer token alone cannot produce it. The server trusts that Snag made the face_id key with biometric access control; there is no hardware attestation.

What Snag says

  • Acknowledgements are 8 words or fewer; answers are spoken in chunks of up to 25 words, with "More, or park it?" between them, three chunks at most.
  • Markdown, links and code blocks are removed before speaking.
  • Write spoken and spoken_prompt for the ear: short, plain, no lists.

A minimal server

The outline below answers asks at once, runs jobs in the background and proposes one approval. Any language works; this is Python-shaped pseudocode.

TOKEN = "<token>"
tasks = {}      # id -> {"state", "speech", "approval"}
issued_ids = set()  # every task id handed out, kept after the task is dropped
seen_keys = {}  # Idempotency-Key -> response

def auth(req):                           # call first in every handler
    if req.headers.get("Authorization") != f"Bearer {TOKEN}":
        return 401

@get("/snag/v1/agents")
def agents(req):
    return 200, {"agents": [{
        "id": "coach", "spoken_name": "coach",
        "can": ["ask", "dispatch", "approve"],
        "sensitivity": "personal", "ask_timeout": 20,
    }]}

@post("/snag/v1/ask")
def ask(req):
    key = req.headers["Idempotency-Key"]
    if key in seen_keys:
        return seen_keys[key]                # the stored response
    text = answer_quickly(req.json["utterance"], limit=req.json["timeout"])
    if text:
        resp = 200, {"answer": {"spoken": text}}
    else:
        resp = 202, {"task": {"id": start_job(req.json), "state": "working"}}
    seen_keys[key] = resp
    return resp

@post("/snag/v1/tasks")
def dispatch(req):
    task_id = start_job(req.json)          # runs in the background
    return 202, {"task": {"id": task_id, "state": "submitted"}}

@get("/snag/v1/tasks/<id>")
def task(req, id):
    t = tasks.get(id)
    if not t:
        return 410 if id in issued_ids else 404
    return 200, {"task": {"id": id, **t}}

@get("/snag/v1/attention")
def attention(req):
    pending = [t["approval"] for t in tasks.values()
               if t["state"] == "needs_approval"]
    return 200, {"items": [{"type": "approval", "agent_id": "coach",
                            "approval": a} for a in pending]}

@post("/snag/v1/approvals/<id>")
def decide(req, id):
    a = find_approval(id)
    if req.json["decision"] != "approve":
        return 200, {}
    if a["class"] == "destructive" and req.json["context"]["driving"]:
        return 409, {"spoken": "That one waits until you've stopped."}
    run_action(a)
    return 200, {"spoken": "Done. Booked for Thursday."}

When a job finishes, set its task to {"state": "completed", "speech": {"digest": ["..."]}}. When it needs a decision, set {"state": "needs_approval", "approval": {...}}.

See also

Next: Nextcloud Talk agents