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
- In Snag, open the Control tab and add an agent.
- Pick Add an agent server ("Your own agent server, using the Snag agent API").
- Enter the Base URL (for example
https://agent.example.com/snag/v1/) and the Key, then tap Connect. - 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
| Item | Value |
|---|---|
| Base URL | Any path. Snag appends the endpoint path to it ({base}/agents, {base}/tasks/{id}) |
| Scheme | https. Plain http only to a local network address (iOS allows local networking only) |
| Auth | Authorization: Bearer <token> on every request. With no key saved, Snag sends nothing and treats the agent as signed out |
| Headers on every request | Accept: application/json, User-Agent: Snag |
| Headers on every POST | Content-Type: application/json, Idempotency-Key: <UUID> (new for each request) |
| Request timeout | 60 s; POST ask waits its timeout and 15 s more |
| Dates | ISO 8601 without fractional seconds, e.g. 2026-10-16T08:30:00Z |
| Push | None. Snag polls (see Polling) |
Endpoints
| Method | Path | Called when |
|---|---|---|
| GET | agents | Snag starts, and whenever Control settings change |
| POST | ask | A question for an agent ("Coach, how did my run go?") |
| POST | tasks | A job for an agent ("Ask ops to check the backups") |
| GET | tasks/{id} | Polling a task still in flight |
| GET | attention | Polling for pending and held approvals, only when one of your agents lists approve in can |
| POST | approvals/{id} | The user approved, held or denied an approval |
| POST | devices | Snag registers or replaces this phone's approval keys, only when GET agents has approval_auth |
| DELETE | devices/{device_id} | The user removes this server from Snag, or turns approval signing off |
| POST | tasks/{id}/park | Reserved. 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/jsonResponse 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
}
]
}| Field | Required | Meaning |
|---|---|---|
id | Yes | Your agent id. Sent back as agent_id in every request |
spoken_name | No | The name the user says. Falls back to name, then id |
name | No | Display name, used only when spoken_name is missing |
aliases | No | Other names the user may say ("my coach") |
can | No | Any of ask, dispatch, approve. Unknown values are dropped. Empty: the agent can take nothing |
sensitivity | No | normal, personal or private (see Sensitivity). Missing: normal. Unknown: private |
approval_classes | No | The approval classes the agent may propose. Unknown values are dropped |
ask_timeout | No | Seconds an ask waits for its answer before it becomes In flight. Default 30, 1 to 300 |
Response, top level, optional:
{"agents": [...], "approval_auth": 1}| Field | Required | Meaning |
|---|---|---|
approval_auth | No | 1: 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"
}| Field | Required | Meaning |
|---|---|---|
device_id | Yes | A UUID Snag makes per server, so two servers cannot link the same phone |
keys | Yes | One device key, and a face_id key when Face ID is set up on the phone |
keys[].use | Yes | device (signs every method but face_id) or face_id (signs face_id only) |
keys[].kid | Yes | The key's RFC 7638 JWK thumbprint: SHA-256 of {"crv":"P-256","kty":"EC","x":"…","y":"…"}, base64url |
keys[].jwk | Yes | The public key, kty: EC, crv: P-256 |
signed_at | Yes | When Snag signed it |
sig | Yes | Signature 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"}}status | Meaning |
|---|---|
active | The keys are in use from now |
pending | Stored 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_idkey (after a Face ID enrolment change) is aPOST deviceswith the samedevice_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 answerpendingand 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_idand registers again, as a new phone. DELETE devices/{device_id}revokes both keys (response204or200). 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:requiresstill applies, and the next phone ispendinguntil 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 field | Required | Meaning |
|---|---|---|
spoken | Yes | The text to say. Write for the ear: plain sentences, 25 words or fewer |
detail or detail_url | No | Longer text or a link. Kept with the answer for the phone |
sensitivity | No | This 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"
}
}state | What Snag does |
|---|---|
completed | The answer is speech.digest joined with spaces; if empty, speech.status; if neither, "Done.". It waits in Waiting on you to be heard |
needs_approval | Reads task.approval (see Approvals) and puts it in Waiting on you. Without an approval object the task stays in flight |
failed, canceled | Marked failed, with speech.status as the reason |
parked | Moved 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 field | Required | Meaning |
|---|---|---|
step | No | What 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_at | No | When 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_atis 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 acompleted,failedorparkedtask 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:
| Status | What Snag does |
|---|---|
410 | The server has dropped the task. Marked failed at once: "Coach lost that one." |
404 | Marked 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"
}
}| Field | Meaning |
|---|---|
decision | approve, hold or deny |
heard_phrase | What 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 |
context | Where the decision was made. driving: true means the car |
auth | Optional. 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.
| Status | Snag treats it as | What the user hears |
|---|---|---|
| 2xx | Success; the body is read | The answer or acknowledgement |
| 401, 403 | Signed out | "Coach needs signing in. Parked." |
| 404 | Not set up | "Couldn't reach coach. Parked." |
| 410 | Gone (GET tasks/{id} only) | Nothing. The task is marked failed: "Coach lost that one." |
| 422 | Refused (POST ask, POST tasks: unknown agent) | Your spoken reason. Snag reads GET agents again |
| 408, 429, 5xx, no connection | Unreachable | "Couldn't reach coach. Parked." |
| Any other 4xx | Refused | Your spoken reason, or "Couldn't reach coach. Parked." without one |
| 2xx with a body Snag cannot read | Bad 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 andGET attentionfor servers with anapproveagent. - 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.
| Field | Values |
|---|---|
driving | true from CarPlay or when audio goes to the car |
source | carplay, siri (a voice intent) or phone |
locale | The phone's locale, e.g. en_GB |
session_id | A UUID per exchange |
Sensitivity
Per agent, and optionally per answer. The stricter applies.
| Level | Shown in Snag as | Spoken |
|---|---|---|
normal | Everyday | Freely |
personal | Personal | Only after a yes to "Say it out loud?" (this version asks everywhere, as for Private) |
private | Private | Only 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.
class | On the phone and in the car | While driving |
|---|---|---|
routine | Snag reads spoken_prompt, then "Approve or hold?". Only "approve" approves | Allowed |
consequential | Snag reads spoken_prompt, then "Say book it to approve." The user must repeat confirm_phrase | Allowed when car_allowed is true |
destructive | On 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 |
| unknown | Treated as destructive | Never |
| Approval field | Required | Meaning |
|---|---|---|
id | Yes | Used in POST approvals/{id} |
action | Yes | A stable machine id for the action type, e.g. calendar.book. The user's per-action rules key on it, so keep it stable |
class | Yes | routine, consequential or destructive |
spoken_prompt | Yes | One line read back before the question. 20 words or fewer |
confirm_phrase | Consequential and destructive | What the user repeats. Without one, the approval needs the phone: "That one needs the phone. Parked." |
target | No | What the action applies to |
task_id | No | The task it belongs to |
car_allowed | No | Default true. Always false for destructive, whatever is sent |
expires_at | No | After this, Snag says "That one has expired. Held." and sends hold |
state | No | pending, approved, held, denied, expired or parked. Default pending |
requires | No | face_id or code_word: what this approval needs on top of its class (see Approval assertions). Snag asks for it up front |
auth_nonce | No | A 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
requiresasks 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:
| Key | Made with | Signs |
|---|---|---|
device | Usable while the phone isn't locked, this device only | voice, phrase, screen, code_word, and every hold and deny |
face_id | Biometric access control (current enrolment only). A passcode cannot use it, and a new face or finger enrolled on the phone invalidates it | face_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.
method | The person |
|---|---|
voice | Said "approve" to a routine approval |
phrase | Repeated the confirm_phrase |
screen | Tapped on the phone or the car screen, or used the phone's passcode |
face_id | Approved on the phone with Face ID |
code_word | Said 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'sidas received, not URL-encoded.heard_sha256: SHA-256 ofheard_phraseexactly as sent (UTF-8), base64url without padding; an empty line when there is noheard_phrase.auth_nonce: the approval's latestauth_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:
vis1, anddevice_idandkidname an active key registered under this bearer token.- The key's
usefits the method (face_idkey forface_id, device key for the rest). sigverifies over the string rebuilt from the request and your ownauth_nonce.signed_atis 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:
requires | Accepted methods |
|---|---|
face_id | face_id |
code_word | code_word or face_id |
What Snag does with requires:
| Where | face_id | code_word |
|---|---|---|
| Approval card | "Needs Face ID" | "Needs your code word" |
| Phone | Face ID, whatever the user's own rule | Face ID |
| Car, by voice or on the car screen | "That one needs Face ID. Approve it on the phone." Parked; your server is not called | Asks 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
spokenandspoken_promptfor 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
- Nextcloud Talk agents: the same agents through a Talk room, no server of your own.
- OpenAI-compatible endpoints: ask-only agents behind a chat completions server.
- Snag Control: what the user says and hears.
Next: Nextcloud Talk agents