Agent Intents Workspace-scoped, short-lived declarations of what an agent is doing, so peers see a collision before it happens instead of after.
Agent Intents let agents working in a shared workspace say what they are doing, so a peer sees a collision before it happens instead of after. It is deliberately not a memory or chat feature: an intent is short-lived, workspace-scoped, and expires automatically, and its content is agent-authored text that must be treated as untrusted and display-only (see Untrusted Content below).
Every route below is workspace-only — there is no share variant — and requires workspace membership at Member level or above, the same bar the storage file-locking identity fields use: a caller below Member does not get to see who is doing what.
Endpoint Summary
| Method | Endpoint | Description |
|---|---|---|
| POST | /current/workspace/{workspace_id}/intents/ | Allocate a slot (content-free) |
| GET | /current/workspace/{workspace_id}/intents/ | Browse the workspace's live intents (topics only) |
| POST | /current/workspace/{workspace_id}/intents/{intent_id}/ | Fill or refine a slot (also the heartbeat) |
| GET | /current/workspace/{workspace_id}/intents/{id1},{id2}/ | Expand one or more intents (full detail, batched) |
| DELETE | /current/workspace/{workspace_id}/intents/{intent_id}/ | Release a slot |
The Intent Object
Allocate, fill, and expand all return this full shape. Browse returns the same shape minus message — see Reading Intents below.
| Field | Type | Description |
|---|---|---|
| id | string | OpaqueId handle for this intent |
| sequence | integer | Orders the workspace's intents by allocation. Treat it as an opaque ordering key — do not do arithmetic on it or infer a position or a count from it. Stamped once at allocation; a later fill never changes it. |
| state | string | allocated, filled, or released |
| intent | string or null | One value from the closed vocabulary below, or null before the slot has been given one |
| node_id | string or null | The node this declaration is about, or null for a workspace-wide declaration. This is what the agent declared, and it is always accurate — see The Named Node below |
| node | object or null | {"id": "...", "name": "...", "type": "..."} — what node_id resolves to now. Rides beside node_id, never in place of it, and is legitimately null — see The Named Node below |
| topic | string or null | Agent-authored one-line label. null on an unfilled slot — this is the occupancy signal, see Pre-Allocation below |
| message | string or null | Agent-authored long-form detail. Present on allocate, fill, and expand responses; absent (not merely null) on every browse row |
| has_message | boolean | Whether a message exists, without sending it — the browse row's substitute for message |
| locker_uid | string | The holder's account id, as a string. Sits beside locker, not inside it — see Attribution below |
| locker | object | {"display_name": "...", "agent_name": "...", "agent_name_source": "..."} — see Attribution below |
| since | string | When the slot was allocated (YYYY-MM-DD HH:MM:SS UTC). Unchanged by a later fill — but a re-allocate that crosses a generation boundary carries a new one, see Pre-Allocation below |
| expires_at | string | When the slot evaporates absent another write (YYYY-MM-DD HH:MM:SS UTC). Every allocate or fill call pushes this forward — see Fill Is the Heartbeat |
| version | integer | Optimistic-concurrency token. Send it back as version on a fill to make the write conditional — see Filling Is a Compare-and-Set |
This library's own errors carry codes in the 9660–9669 range; each endpoint's error table below lists exactly which ones it can return.
Intent Vocabulary
intent is a closed vocabulary. An unrecognized value is rejected with 406, never silently accepted and ignored:
| Value | Typical use |
|---|---|
edit | Modifying a file's or note's content |
read | Reading or reviewing, without changing anything |
upload | Adding new content |
move | Relocating or reorganizing a node |
delete | Removing content |
analyze | Inspecting or summarizing without changing content |
organize | Restructuring — e.g. folder cleanup |
intent is optional on both allocate and fill; an unfilled slot's intent reads null until one is set.
Attribution (locker_uid and locker)
The holder is reported in two parts, and this is byte-for-byte the shape the storage file-locking lock_info block uses — same key names, same nesting — so one renderer answers “who is touching this file” whether the answer came from a lock or from an intent (see the Storage reference).
| Field | Source | Description |
|---|---|---|
| locker_uid | Stored on the row | The holder's account id, as a string. Top-level, beside locker — not inside it. |
| locker.display_name | Resolved at read time | The holder's name, or null when the holder cannot be named |
| locker.agent_name | Stored on the row | The agent that allocated the slot, or null when a person acted directly. Stamped once at allocation and never updated by a later fill — see below |
| locker.agent_name_source | Stored on the row | Where that name came from — jwt_claim or api_key_label; null alongside a null agent_name |
locker.display_name is null when the holder cannot be named — that is not the intent failing to exist. A slot whose holder no longer resolves is still a live, valid slot held by a real account: locker_uid still identifies it, agent_name still says which agent filed it, and the row must still be rendered. The name is a convenience resolved at read time; the id beside it is the durable answer. Key off locker_uid, never off display_name.
agent_name comes from the calling CREDENTIAL, never from anything sent on an intents call. There is no agent_name parameter on allocate or fill; nothing a caller sends can set, override, or suppress it. It is self-declared, not verified — display it beside the holder, never use it to identify or authorize anyone.
🔴 agent_name names the agent that ALLOCATED the slot — NOT whoever last wrote to it. Because ownership is scoped to the user (see Filling Is a Compare-and-Set), a sibling agent of the same operator can fill a slot it did not allocate — and when it does, agent_name does not change. So a row can legitimately read "Design Bot" beside a topic and message composed by "Copywriter Bot". This is deliberate rather than a gap: agent_name is part of the slot's identity key (workspace, node, user, agent_name), so it has to stay fixed or the slot would move out from under its own key.
The practical consequence for anything that renders it: agent_name tells you which declaration this is, never who wrote the words in it. Label it accordingly — "slot held by X" rather than "X says" — because attributing the sentence to the agent named beside it is wrong precisely in the multi-agent case this system exists to describe.
The Named Node (node_id and node)
The node an intent names is reported in the same two-part way, for the same reason:
node_idis what the agent declared. It is always accurate, and it never changes for the life of the slot.nodeis what that id resolves to right now —{"id", "name", "type"}, withidin the same hyphenated form every storage surface returns, so it compares equal to ids you already hold.
node rides beside node_id; it never replaces it.
⚠ node: null with a non-null node_id is a legitimate state, not an error. It means the file was deleted (or is no longer readable) after the intent was filed. The declaration was true when it was made, the slot is still valid occupancy, and it still expires on its own — render the row; you simply cannot name the file. A client that treats this as a broken record, hides the row, or fails the read has removed exactly the occupancy signal this surface exists to publish.
Both are null together for a workspace-wide intent, which is the other reason node can be null. Tell the two apart by node_id: null means workspace-wide, non-null means the node no longer resolves.
⚠ node.name is a FILENAME — user-supplied text, and a different provenance from topic and message. Those two are agent-authored declarations written through this API; node.name is whatever a person named a file. All three are untrusted display-only text and are escaped at the render boundary, but they do not come from the same place: do not describe or treat node.name as agent-authored, and do not assume the topic/message intake rules in Untrusted Content apply to it — those are enforced on what this API accepts, and a filename is not written here.
Pre-Allocation: an Unfilled Slot Is Occupancy
Allocating a slot takes no content. That is deliberate, not a two-step workaround: a slot is taken the moment work starts, before the agent has decided what to say about it, so state: "allocated" with a null topic means someone is starting something here. Render these rows — do not filter them out. Hiding an unfilled slot removes the entire point of allocating early, which is to make a collision visible during the window before an agent has written anything down.
Ordering follows the same idea: intents list by allocation order, never by when they were last filled, because the question the list answers is “who got here first” — an agent that allocated early and fills late still sorts ahead of one that allocated later.
🔴 Allocate is GET-OR-CREATE, not "mint a new slot", and the slot it finds may be one you have no memory of holding. The key is your credential plus the scope you asked for — (workspace, node_id, user, agent_name) — so allocating with no node_id returns the workspace-wide slot for that credential, whether you took it a second ago or an hour ago in a process that has since restarted. There is no verb that mints a guaranteed-fresh slot.
This matters most when you wanted a throwaway. If you allocate expecting a new slot and then release what comes back, you have destroyed a live declaration — possibly one carrying a topic and message that something else published and is still relying on.
Tell the two apart from the response, because nothing else will: a genuinely fresh slot comes back state: "allocated", version: 0, topic: null, message: null. Anything else means you were handed a slot that already existed — do not release it, and do not assume its content is yours to overwrite. If you need a slot that cannot collide with an existing one, change the scope (a different node_id) or use a different agent credential.
Given that, allocating twice with the same credential is idempotent on the slot: it re-asserts the slot rather than creating a second one, so a released slot is available for reuse on new work. What that second call actually does depends entirely on whether the slot is still alive when it arrives — and the two outcomes are very different.
While the slot is live, a repeat allocate is a pure heartbeat. It pushes expires_at forward and leaves id, since, version, state, topic and message exactly as they were. Nothing you have published is lost.
intent is the one field that call CAN change. Send a different intent on the repeat allocate and the live slot's intent is updated in place; everything else still carries over untouched. Omitting intent (or sending it empty) leaves the existing value alone — there is no way to clear an intent, only to replace it, so a defensive re-allocate that does not mention one cannot wipe what you declared.
⚠ Once the slot has lapsed or been released, the same call does not revive it — it mints a NEW GENERATION. You get a new id (the handle you were holding now addresses nothing), version back to 0, state back to allocated, a new since, and no topic or message. Nothing in the response marks that boundary, so compare the id you get back against the one you were holding.
A client that cached an id and version across an expiry gap gets 404 on fill, and expand simply omits the row — with no way to tell a rotation from an id that never existed. 🔴 release does NOT behave the same way. Release has no expiry condition: releasing a merely-lapsed slot that has not yet been released succeeds with 200, because the row is still there and expiry is only a read filter. So a 200 from release is not evidence the slot was live, and it is not a way to probe liveness — read the slot if you need to know.
since moves across that boundary, which is the one exception to its rule above: since is unchanged by a fill, always, but a new generation is a new slot and carries a new since.
So if you hold a slot, FILL it. Fill is the heartbeat and preserves everything you have published. A defensive re-allocate on resume or reconnect — the natural thing for an agent to reach for — buys nothing a fill does not, and silently rotates your handle if the slot lapsed in the meantime.
Fill Is the Heartbeat
There is no separate renewal endpoint, on this surface or any other. Every write to a slot — an allocate (including re-allocating one you already hold) or a fill — pushes expires_at forward by the same interval. An agent that goes silent has its intent evaporate, because staying visible is a side effect of doing the work, not a standalone claim about it. If a long-running task should keep showing up, fill periodically; there is nothing else to call.
Stale intents fail open: they are advisory and never gate a write anywhere else in the API. They simply stop appearing once they expire.
Filling Is a Compare-and-Set
A fill requires the version you last read for that slot. Omitting version is refused with 409, the same as sending a stale one — a write that does not name what it is replacing is never allowed to proceed blindly. On a 409, re-read the intent (via browse or expand) and decide again against the current state. Do not retry the identical payload — the version has moved, and a blind retry can silently discard whatever the peer just wrote.
Ownership is scoped to the USER, not to the credential. Any credential authenticated as the user who holds a slot may fill or release it, so sibling agents of one operator can fill and release each other's slots. That is deliberate, and it is the ordinary case rather than a caveat: the dominant shape of a workspace is several agents acting for one person, and they are meant to be able to pick up each other's declarations — refine a topic, or release a slot whose agent has stopped — instead of one agent's slot becoming permanently unreachable the moment that agent goes away.
This is a different question from what makes a slot distinct. The slot key stays credential-scoped — (workspace, node, user, agent_name) — so five agents of one operator still hold five separate slots. Slot identity is credential-keyed; slot authorization is user-scoped.
A fill or release aimed at a slot your user does not hold answers 404 — identical to a nonexistent id, by design: there is no way to tell “not yours” from “does not exist” from the response.
Reading Intents
Two surfaces, deliberately shaped differently:
| Surface | Returns | Notes |
|---|---|---|
Browse (GET .../intents/) | Every live slot's topic — message is absent, not empty | Cheap: a full workspace picture for a few tokens |
Expand (GET .../intents/{id1},{id2}/) | The full record, including message, for the ids you name | Batched — ask for as many ids as you actually decided to read, in one call |
Use has_message on a browse row to decide whether an id is worth expanding.
No rows are ever held back. Every page is fully live, including a paged one — a declaration is visible the moment it is written, because occupancy a peer cannot yet see is worse than useless.
What lags by ~2 seconds is the cursor, not the rows. The cursor never advances past a boundary that may still be receiving writes, so a reader who pages beyond it can never skip a slower-committing declaration. The cost is that a paging caller may revisit the tail of a page it has already seen; repetition is recoverable, a silent drop is not. Treat repeated ids as expected and de-duplicate on id.
🔴 Browse does not filter, but it does not refuse a filter either. node_id and intent are accepted by the input spec and then silently ignored — ?intent=edit answers 200 with every live intent in the workspace, not the edits. There is no error to tell you the narrowing did not happen, so a caller that trusts the parameter reads an unfiltered list as a filtered one. Filter by intent or node_id client-side on the returned rows.
Untrusted Content
topic and message are agent-authored and must be treated as untrusted, display-only text:
- Both are escaped at the render boundary before being emitted anywhere, exactly like every other string this API returns.
- Both are length-bounded —
topicto 256 characters,messageto 8192 characters — counted in characters, not bytes. - Control characters (C0/C1, bidi overrides, zero-width formatting, line/paragraph separators) are rejected, not silently stripped.
topicadditionally rejects newlines and tabs, because it is a one-line label.
🛑 Escaping stops a string from breaking a renderer. It does nothing to stop a model from obeying it. Those are different failures, and the second is worse. Display topic and message as labels; never pass them to a model as instructions, and never treat them as verified or authoritative just because they came back from this API.
The rules in this section are about what this API accepts — they describe topic and message, which are agent-authored here. node.name is also untrusted display-only text, but it is a user-supplied filename that arrives from storage rather than from an intents call, so it is neither agent-authored nor governed by the bounds above — see The Named Node above.
Allocate an Intent
/current/workspace/{workspace_id}/intents/
Allocate a slot for the calling credential in this workspace.
Auth: JWT required. Workspace membership (Member or above) required.
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| {workspace_id} | string | Yes | 19-digit numeric workspace ID |
Request Body (form-encoded)
| Parameter | Type | Required | Constraints | Description |
|---|---|---|---|---|
| node_id | string | No | Must be a node in this workspace | Scopes the declaration to one node. Omit (or send empty) for a workspace-wide intent. |
| intent | string | No | One of the closed vocabulary values above | What kind of work this is. The one field a repeat allocate on a live slot can change — send a new value to replace it; omit it (or send empty) and the existing value is kept. There is no way to clear it. May also be set or changed later on a fill. |
No topic or message here — allocation is content-free by design; call fill once you know what to say.
curl Example
curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/intents/" \
-H "Authorization: Bearer {jwt_token}" \
-d 'node_id=aad2i-eo63m-ynpyr-ownhw-yaoql-npqzp' \
-d 'intent=edit'
Response
{
"result": true,
"intent": {
"id": "aaksp-tdj5n-pxrkt-sme7o-l2th5-roact",
"sequence": 41,
"state": "allocated",
"intent": "edit",
"node_id": "aad2i-eo63m-ynpyr-ownhw-yaoql-npqzp",
"node": {
"id": "aad2i-eo63m-ynpyr-ownhw-yaoql-npqzp",
"name": "upload-pipeline.md",
"type": "file"
},
"topic": null,
"locker_uid": "9876543210987654321",
"locker": {
"display_name": "Ellen Ripley",
"agent_name": "Claude-2",
"agent_name_source": "api_key_label"
},
"has_message": false,
"since": "2026-08-27 18:00:00 UTC",
"expires_at": "2026-08-27 19:00:00 UTC",
"version": 0,
"message": null
}
}
Response Fields: See The Intent Object above.
Error Responses
| Error Code | HTTP Status | Cause |
|---|---|---|
137161 (Not Found) | 404 | node_id does not exist, or is not associated with this workspace |
9666 (Not Found) | 404 | The workspace could not be resolved to a valid scope |
9665 (Invalid Input) | 406 | intent is not one of the closed vocabulary values |
10581 (Not Found) | 404 | {workspace_id} does not exist or is not visible to the caller |
10545 (Access Denied) | 401 | Caller is below Member in this workspace |
10011 (Authentication Invalid) | 401 | Missing or invalid bearer token |
165841 (Internal Error) | 500 | The node_id you named could not be verified — storage itself failed, which is not the same as the node being absent. Retryable, unlike the 404 above: the node may well be there. Do not treat this as “no such node” |
9661 (Internal Error) | 500 | The slot could not be written — a service-side persistence failure, not anything about your request. Retryable |
Browse Intents
/current/workspace/{workspace_id}/intents/
Browse every live intent in the workspace, ordered by allocation. Topics only — see Reading Intents above.
Auth: JWT required. Workspace membership (Member or above) required.
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| {workspace_id} | string | Yes | 19-digit numeric workspace ID |
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| cursor | string | No | Opaque cursor from a previous response's intents.cursor. Omit for the first page. |
Page size is fixed at up to 100 items; there is no page_size parameter on this endpoint.
curl Example
curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/intents/" \
-H "Authorization: Bearer {jwt_token}"
Response
{
"result": true,
"intents": {
"count": 3,
"items": [
{
"id": "aaksp-tdj5n-pxrkt-sme7o-l2th5-roact",
"sequence": 41,
"state": "allocated",
"intent": "edit",
"node_id": "aad2i-eo63m-ynpyr-ownhw-yaoql-npqzp",
"node": {
"id": "aad2i-eo63m-ynpyr-ownhw-yaoql-npqzp",
"name": "upload-pipeline.md",
"type": "file"
},
"topic": null,
"locker_uid": "9876543210987654321",
"locker": {
"display_name": "Ellen Ripley",
"agent_name": "Claude-2",
"agent_name_source": "api_key_label"
},
"has_message": false,
"since": "2026-08-27 18:00:00 UTC",
"expires_at": "2026-08-27 19:00:00 UTC",
"version": 0
},
{
"id": "aaxgj-edop7-6he2q-a7xc3-itgrt-6xu2b",
"sequence": 42,
"state": "filled",
"intent": "analyze",
"node_id": null,
"node": null,
"topic": "Summarizing the Q3 board deck",
"locker_uid": "1234567890123456789",
"locker": {
"display_name": "Dwayne Hicks",
"agent_name": null,
"agent_name_source": null
},
"has_message": true,
"since": "2026-08-27 18:01:12 UTC",
"expires_at": "2026-08-27 19:01:40 UTC",
"version": 3
},
{
"id": "aaktk-n2k5u-vemye-li2ho-liuu4-w4msg",
"sequence": 43,
"state": "filled",
"intent": "delete",
"node_id": "aafuc-qgqby-oq4bj-c3r7z-htsl2-7g4g6",
"node": null,
"topic": "Clearing out the superseded exports",
"locker_uid": "2447646925323621052",
"locker": {
"display_name": null,
"agent_name": "Claude-7",
"agent_name_source": "jwt_claim"
},
"has_message": false,
"since": "2026-08-27 18:02:04 UTC",
"expires_at": "2026-08-27 19:02:04 UTC",
"version": 1
}
],
"cursor": "43"
}
}
The third row shows both resolutions coming back empty, and neither is an error: node is null because the file it names has since been deleted — node_id still says which file the declaration was about — and display_name is null because that holder can no longer be named, while locker_uid and agent_name still say who holds it. The two resolutions are independent — either can be null without the other, and a row with both null is still live occupancy that must be rendered.
Response Fields
| Field | Type | Description |
|---|---|---|
| intents.count | integer | Number of items in this page |
| intents.items | array | Browse-shaped intent objects — see The Intent Object (no message) |
| intents.cursor | string or null | Pass back as cursor to read the next page. null means no safe checkpoint yet — NOT end-of-list; it can accompany a full items array. See below. |
There is no has_more flag on this endpoint, and a page with fewer than 100 items is not necessarily the last one.
🔴 cursor: null does NOT mean end-of-list, and it is NOT equivalent to an empty items array. The cursor is only advanced to rows old enough to be past the write-visibility boundary, so a page whose rows were all written in roughly the last 2 seconds comes back with cursor: null and a full items array. That is the server saying "no safe checkpoint yet", not "nothing left" — and it is exactly what a burst of agents starting work at once produces.
itemsnon-empty — there is more to read. Ifcursorisnull, re-poll with the cursor you already had (or with none, if you had none) after a short wait. Do not stop.itemsempty — that is the end of the list. This is the only end-of-list signal.
Treating cursor: null as end-of-list will silently truncate your read, dropping every intent after it.
Error Responses
| Error Code | HTTP Status | Cause |
|---|---|---|
9666 (Not Found) | 404 | The workspace could not be resolved to a valid scope |
9661 (Internal Error) | 500 | The intent store could not be read |
10581 (Not Found) | 404 | {workspace_id} does not exist or is not visible to the caller |
10545 (Access Denied) | 401 | Caller is below Member in this workspace |
10011 (Authentication Invalid) | 401 | Missing or invalid bearer token |
Fill / Refine an Intent
/current/workspace/{workspace_id}/intents/{intent_id}/
Fill or refine a slot you already hold, and push its expires_at forward. This call is the heartbeat — there is no separate renewal endpoint.
Auth: JWT required. Workspace membership (Member or above) required. Any credential authenticated as the user who holds {intent_id} may fill it, including a sibling agent of the one that allocated it — see Filling Is a Compare-and-Set above.
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| {workspace_id} | string | Yes | 19-digit numeric workspace ID |
| {intent_id} | string | Yes | OpaqueId returned from allocate (or from a browse/expand row) |
Request Body (form-encoded)
| Parameter | Type | Required | Constraints | Description |
|---|---|---|---|---|
| version | integer | Yes | Must match the slot's current version | Compare-and-set token from your last read of this intent. Omitted or stale ⇒ 409. |
| topic | string | No | ≤ 256 characters; single-line — see Accepted Characters below | One-line label. Untrusted, agent-authored — see Untrusted Content above. |
| message | string | No | ≤ 8192 characters; newlines and tabs allowed — see Accepted Characters below | Long-form detail. Untrusted, agent-authored — see Untrusted Content above. |
| intent | string | No | One of the closed vocabulary values above | Sets or changes the kind of work this slot represents. |
Accepted Characters (topic and message)
Both fields reject a set of invisible characters that would let agent-authored text misrepresent how it displays. A rejected character fails the whole write with 9664 / 406; nothing is silently stripped, and every non-empty accepted value is stored verbatim. (An empty string is treated as omitted, not as a clear — see Clearing a field below.)
This is the COMPLETE rejected set, not a sample. Any other character is accepted. (Separately, a request body that is not valid UTF-8 is rejected outright — that is an encoding failure rather than a character rule.)
| Rejected in both fields | |
|---|---|
U+0000–U+0008, U+000B, U+000C, U+000E–U+001F | C0 controls, excluding tab/LF/CR |
U+007F | delete |
U+0080–U+009F | the whole C1 range |
U+061C | arabic letter mark |
U+200B–U+200F | zero-width space/non-joiner/joiner, LTR & RTL marks |
U+202A–U+202E | bidirectional embedding and override |
U+2028, U+2029 | line and paragraph separator |
U+2060 | word joiner |
U+2066–U+2069 | bidirectional isolates |
U+FEFF | zero-width no-break space / BOM |
U+E0000–U+E007F | tag characters |
| Field | Tab U+0009, newline U+000A, carriage return U+000D |
|---|---|
topic | rejected — topic is a single-line label |
message | accepted, stored verbatim |
Everything else is accepted, including several characters that are invisible or unusual and are often assumed to be filtered: soft hyphen U+00AD, Hangul filler U+3164, interlinear annotation U+FFF9, no-break space U+00A0, ideographic space U+3000, private-use U+E000, combining marks, CJK, and standalone emoji.
⚠️ A multi-code-point emoji SEQUENCE can be rejected even though each visible glyph is fine. A ZWJ sequence (family, profession and most flag-neutral composites) is joined by U+200D, and a tag sequence (the subdivision flags such as the Scotland or Wales flag) is built from U+E0000–U+E007F — both are in the rejected set above, so the whole write fails with 9664. A single emoji code point is accepted. If you accept emoji from users and forward them here, expect this and handle it; it is the most common way a well-formed-looking value is refused.
Clearing a field
There is no way to clear topic or message through this endpoint. An empty string is treated as omitted, not as a clear: the field keeps its previous value, and a write consisting only of empty values is a pure keepalive. A client that sends topic="" to blank a label will get a 200 and no change — release the slot instead if the declaration is over.
The rejected set is deliberately narrower than “all invisible characters”: it targets the ones that change the apparent order or content of a string without being visible in it — bidirectional overrides and zero-width joiners survive HTML-escaping unchanged, so a consumer that escapes correctly can still be shown something other than what was written. Markup is not rejected — raw HTML, markdown and entities are all stored verbatim and are the consumer's responsibility to render inertly.
⚠️ This check reads decoded UTF-8 code points, so it does NOT cover an ENCODED representation of a rejected character. ‮ is eight printable-ASCII characters, passes validation, and is stored verbatim — exactly as the table above says markup is. A consumer that decodes entities on the way to the screen therefore has to do its own filtering after decoding, because by the time the character exists it is past this boundary. Do not treat these rejections as a substitute for output-side handling; they remove a class of input, not a class of rendering.
A fill that supplies none of topic, message, or intent is a pure keepalive: it still requires a valid version and still advances version/expires_at, but it leaves state as-is rather than flipping an unfilled slot to filled — a keepalive on an empty slot must not report state: "filled" with a null topic, which is the exact shape defined as unfilled occupancy.
🔴 intent alone does NOT flip the state either. Only topic or message — real content — moves an allocated slot to filled. intent classifies a slot; it does not say anything has been declared about it, so fill(intent=read) with no topic and no message is written, advances version and expires_at, and leaves state: "allocated". A client waiting for filled after sending only intent waits forever.
curl Example
curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/intents/aaksp-tdj5n-pxrkt-sme7o-l2th5-roact/" \
-H "Authorization: Bearer {jwt_token}" \
-d 'version=0' \
-d 'topic=Refactoring the upload chunk assembler' \
-d 'message=Splitting the chunk processor into smaller pieces; touching storage/upload only.'
Response
{
"result": true,
"intent": {
"id": "aaksp-tdj5n-pxrkt-sme7o-l2th5-roact",
"sequence": 41,
"state": "filled",
"intent": "edit",
"node_id": "aad2i-eo63m-ynpyr-ownhw-yaoql-npqzp",
"node": {
"id": "aad2i-eo63m-ynpyr-ownhw-yaoql-npqzp",
"name": "upload-pipeline.md",
"type": "file"
},
"topic": "Refactoring the upload chunk assembler",
"locker_uid": "9876543210987654321",
"locker": {
"display_name": "Ellen Ripley",
"agent_name": "Claude-2",
"agent_name_source": "api_key_label"
},
"has_message": true,
"since": "2026-08-27 18:00:00 UTC",
"expires_at": "2026-08-27 19:01:00 UTC",
"version": 1,
"message": "Splitting the chunk processor into smaller pieces; touching storage/upload only."
}
}
Response Fields: See The Intent Object above.
Error Responses
| Error Code | HTTP Status | Cause |
|---|---|---|
9667 (Conflict) | 409 | version was omitted, or does not match the slot's current version. Re-read the intent and decide again — do not retry the same payload. |
9662 (Not Found) | 404 | No such intent, or it belongs to a different user (the two are indistinguishable by design). A slot held by a sibling agent of your own user is not a 404 — that slot is yours to write |
9666 (Not Found) | 404 | The workspace could not be resolved to a valid scope |
9663 (Limit Exceeded) | 413 | topic or message exceeds its maximum length |
9664 (Invalid Input) | 406 | topic or message contains a rejected character — a control character, a zero-width or bidirectional formatting character, or a line/paragraph separator (and for topic only, a newline or tab) |
9665 (Invalid Input) | 406 | intent is not one of the closed vocabulary values |
10581 (Not Found) | 404 | {workspace_id} does not exist or is not visible to the caller |
10545 (Access Denied) | 401 | Caller is below Member in this workspace |
10011 (Authentication Invalid) | 401 | Missing or invalid bearer token |
9661 (Internal Error) | 500 | The fill could not be written — a service-side persistence failure, not anything about your request. Retryable; re-read the slot before retrying, since the version may have moved |
Expand Intents
/current/workspace/{workspace_id}/intents/{id1},{id2}/
Read the full record — including message — for one or more intent ids in a single call, the same batched shape the multi-node storage details read uses.
Auth: JWT required. Workspace membership (Member or above) required.
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| {workspace_id} | string | Yes | 19-digit numeric workspace ID |
| {id1,id2,...} | string | Yes | One or more comma-separated intent ids. Up to 250 ids per call; any beyond that are silently ignored. |
An id that does not exist, has expired, or was released simply does not appear in the response — there is no per-id error entry, and the call still answers 200 OK even when none of the ids resolve to anything. Read intents.count to tell an empty result from a partial one.
curl Example
curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/intents/aaksp-tdj5n-pxrkt-sme7o-l2th5-roact,aaxgj-edop7-6he2q-a7xc3-itgrt-6xu2b/" \
-H "Authorization: Bearer {jwt_token}"
Response
{
"result": true,
"intents": {
"count": 1,
"items": [
{
"id": "aaksp-tdj5n-pxrkt-sme7o-l2th5-roact",
"sequence": 41,
"state": "filled",
"intent": "edit",
"node_id": "aad2i-eo63m-ynpyr-ownhw-yaoql-npqzp",
"node": {
"id": "aad2i-eo63m-ynpyr-ownhw-yaoql-npqzp",
"name": "upload-pipeline.md",
"type": "file"
},
"topic": "Refactoring the upload chunk assembler",
"locker_uid": "9876543210987654321",
"locker": {
"display_name": "Ellen Ripley",
"agent_name": "Claude-2",
"agent_name_source": "api_key_label"
},
"has_message": true,
"since": "2026-08-27 18:00:00 UTC",
"expires_at": "2026-08-27 19:01:00 UTC",
"version": 1,
"message": "Splitting the chunk processor into smaller pieces; touching storage/upload only."
}
]
}
}
Only one item came back here even though two ids were requested — the second had already been released (or never existed) and is simply absent.
Response Fields
| Field | Type | Description |
|---|---|---|
| intents.count | integer | Number of items actually returned — may be fewer than the number of ids requested |
| intents.items | array | Full intent objects, including message — see The Intent Object |
Error Responses
| Error Code | HTTP Status | Cause |
|---|---|---|
9666 (Not Found) | 404 | The workspace could not be resolved to a valid scope |
9661 (Internal Error) | 500 | The intent store could not be read |
10581 (Not Found) | 404 | {workspace_id} does not exist or is not visible to the caller |
10545 (Access Denied) | 401 | Caller is below Member in this workspace |
10011 (Authentication Invalid) | 401 | Missing or invalid bearer token |
Release an Intent
/current/workspace/{workspace_id}/intents/{intent_id}/
Release a slot.
Auth: JWT required. Workspace membership (Member or above) required. Any credential authenticated as the user who holds {intent_id} may release it, including a sibling agent of the one that allocated it — see Filling Is a Compare-and-Set above.
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| {workspace_id} | string | Yes | 19-digit numeric workspace ID |
| {intent_id} | string | Yes | OpaqueId returned from allocate (or from a browse/expand row) |
No request body. Releasing a slot that is already released succeeds (idempotent).
curl Example
curl -X DELETE "https://api.fast.io/current/workspace/1234567890123456789/intents/aaksp-tdj5n-pxrkt-sme7o-l2th5-roact/" \
-H "Authorization: Bearer {jwt_token}"
Response
{
"result": true,
"released": true
}
Error Responses
| Error Code | HTTP Status | Cause |
|---|---|---|
9662 (Not Found) | 404 | No such intent, or it belongs to a different user (the two are indistinguishable by design). A slot held by a sibling agent of your own user is not a 404 — that slot is yours to write |
9666 (Not Found) | 404 | The workspace could not be resolved to a valid scope |
10581 (Not Found) | 404 | {workspace_id} does not exist or is not visible to the caller |
10545 (Access Denied) | 401 | Caller is below Member in this workspace |
10011 (Authentication Invalid) | 401 | Missing or invalid bearer token |