Agent Intents Workspace-scoped, short-lived declarations of what an agent is doing, so peers see a collision before it happens instead of after.

Base URL: https://api.fast.io/current/ Auth: Bearer {jwt_token} Scope: Workspace only

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

MethodEndpointDescription
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.

FieldTypeDescription
idstringOpaqueId handle for this intent
sequenceintegerOrders 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.
statestringallocated, filled, or released
intentstring or nullOne value from the closed vocabulary below, or null before the slot has been given one
node_idstring or nullThe 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
nodeobject 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
topicstring or nullAgent-authored one-line label. null on an unfilled slot — this is the occupancy signal, see Pre-Allocation below
messagestring or nullAgent-authored long-form detail. Present on allocate, fill, and expand responses; absent (not merely null) on every browse row
has_messagebooleanWhether a message exists, without sending it — the browse row's substitute for message
locker_uidstringThe holder's account id, as a string. Sits beside locker, not inside it — see Attribution below
lockerobject{"display_name": "...", "agent_name": "...", "agent_name_source": "..."} — see Attribution below
sincestringWhen 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_atstringWhen 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
versionintegerOptimistic-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 96609669 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:

ValueTypical use
editModifying a file's or note's content
readReading or reviewing, without changing anything
uploadAdding new content
moveRelocating or reorganizing a node
deleteRemoving content
analyzeInspecting or summarizing without changing content
organizeRestructuring — 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).

FieldSourceDescription
locker_uidStored on the rowThe holder's account id, as a string. Top-level, beside locker — not inside it.
locker.display_nameResolved at read timeThe holder's name, or null when the holder cannot be named
locker.agent_nameStored on the rowThe 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_sourceStored on the rowWhere 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 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:

SurfaceReturnsNotes
Browse (GET .../intents/)Every live slot's topic — message is absent, not emptyCheap: a full workspace picture for a few tokens
Expand (GET .../intents/{id1},{id2}/)The full record, including message, for the ids you nameBatched — 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:

🛑 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

POST /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

ParameterTypeRequiredDescription
{workspace_id}stringYes19-digit numeric workspace ID

Request Body (form-encoded)

ParameterTypeRequiredConstraintsDescription
node_idstringNoMust be a node in this workspaceScopes the declaration to one node. Omit (or send empty) for a workspace-wide intent.
intentstringNoOne of the closed vocabulary values aboveWhat 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 CodeHTTP StatusCause
137161 (Not Found)404node_id does not exist, or is not associated with this workspace
9666 (Not Found)404The workspace could not be resolved to a valid scope
9665 (Invalid Input)406intent 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)401Caller is below Member in this workspace
10011 (Authentication Invalid)401Missing or invalid bearer token
165841 (Internal Error)500The 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)500The slot could not be written — a service-side persistence failure, not anything about your request. Retryable

Browse Intents

GET /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

ParameterTypeRequiredDescription
{workspace_id}stringYes19-digit numeric workspace ID

Query Parameters

ParameterTypeRequiredDescription
cursorstringNoOpaque 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

FieldTypeDescription
intents.countintegerNumber of items in this page
intents.itemsarrayBrowse-shaped intent objects — see The Intent Object (no message)
intents.cursorstring or nullPass 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.

Treating cursor: null as end-of-list will silently truncate your read, dropping every intent after it.

Error Responses

Error CodeHTTP StatusCause
9666 (Not Found)404The workspace could not be resolved to a valid scope
9661 (Internal Error)500The 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)401Caller is below Member in this workspace
10011 (Authentication Invalid)401Missing or invalid bearer token

Fill / Refine an Intent

POST /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

ParameterTypeRequiredDescription
{workspace_id}stringYes19-digit numeric workspace ID
{intent_id}stringYesOpaqueId returned from allocate (or from a browse/expand row)

Request Body (form-encoded)

ParameterTypeRequiredConstraintsDescription
versionintegerYesMust match the slot's current versionCompare-and-set token from your last read of this intent. Omitted or stale ⇒ 409.
topicstringNo≤ 256 characters; single-line — see Accepted Characters belowOne-line label. Untrusted, agent-authored — see Untrusted Content above.
messagestringNo≤ 8192 characters; newlines and tabs allowed — see Accepted Characters belowLong-form detail. Untrusted, agent-authored — see Untrusted Content above.
intentstringNoOne of the closed vocabulary values aboveSets 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+0000U+0008, U+000B, U+000C, U+000EU+001FC0 controls, excluding tab/LF/CR
U+007Fdelete
U+0080U+009Fthe whole C1 range
U+061Carabic letter mark
U+200BU+200Fzero-width space/non-joiner/joiner, LTR & RTL marks
U+202AU+202Ebidirectional embedding and override
U+2028, U+2029line and paragraph separator
U+2060word joiner
U+2066U+2069bidirectional isolates
U+FEFFzero-width no-break space / BOM
U+E0000U+E007Ftag characters
FieldTab U+0009, newline U+000A, carriage return U+000D
topicrejectedtopic is a single-line label
messageaccepted, 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+E0000U+E007Fboth 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 CodeHTTP StatusCause
9667 (Conflict)409version 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)404No 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)404The workspace could not be resolved to a valid scope
9663 (Limit Exceeded)413topic or message exceeds its maximum length
9664 (Invalid Input)406topic 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)406intent 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)401Caller is below Member in this workspace
10011 (Authentication Invalid)401Missing or invalid bearer token
9661 (Internal Error)500The 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

GET /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

ParameterTypeRequiredDescription
{workspace_id}stringYes19-digit numeric workspace ID
{id1,id2,...}stringYesOne 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

FieldTypeDescription
intents.countintegerNumber of items actually returned — may be fewer than the number of ids requested
intents.itemsarrayFull intent objects, including message — see The Intent Object

Error Responses

Error CodeHTTP StatusCause
9666 (Not Found)404The workspace could not be resolved to a valid scope
9661 (Internal Error)500The 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)401Caller is below Member in this workspace
10011 (Authentication Invalid)401Missing or invalid bearer token

Release an Intent

DELETE /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

ParameterTypeRequiredDescription
{workspace_id}stringYes19-digit numeric workspace ID
{intent_id}stringYesOpaqueId 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 CodeHTTP StatusCause
9662 (Not Found)404No 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)404The 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)401Caller is below Member in this workspace
10011 (Authentication Invalid)401Missing or invalid bearer token
↑ Back to top