The wire: the state process's HTTP contract
neosian serve exposes durable state through wire 6. This page documents
its twenty-one base storage POST routes, optional search/change routes, and two GET routes. Literal fixtures and
independent raw HTTP, CLI and MCP cases pin behavior without a product SDK.
Machine contracts live in assets/contracts/: wire-v6.openapi.json is
OpenAPI 3.1.1 and state-v1.schema.json is JSON Schema 2020-12. Keep them
together for offline references. Their IDs are identifiers, not served
endpoints: /openapi.json and /contracts are not routes. Print them with
neosian schema wire and neosian schema state, or download the release’s
contracts archive. Typed inputs and canonical outputs are described; not
every legacy decoder coercion is a schema promise.
The built-in FileStore and Postgres backends implement the whole wire.
Custom storage is an independent conformance-tested HTTP service. Backend
capabilities determine paging, erasure and optimistic concurrency.
make test-storage-contract exercises those behaviors; make test adds
physical file failures, frozen v1 compatibility cases and product doors.
The wire serves storage, not an agent runtime. audit is composed from
history and redaction reads; maintenance and semantic reflection are not
wire routes. Product version 2.0.0 retains wire_version: 6 and format 1.
The handshake
Section titled “The handshake”GET /v1/capabilities, authenticated, answers these base keys:
{"wire_version": 6, "neosian_version": "<installed>", "backend": "FileStore", "supports_optimistic_concurrency": true, "pageable": true, "erasable": true, "client": "client:default"}backend is the class serving the store, pageable whether its
listings answer pages (below), erasable whether its turns can be
redacted (the two erasure routes below), and client the actor the
presented token makes the caller. GET /health is the one unauthenticated route
and answers {"status": "ok"}, liveness only.
The capability fields are independent. A client requiring concurrent
version fences checks supports_optimistic_concurrency; a client following
memory cursors checks pageable; turn erasure requires erasable.
Whole-store portability has no handshake field in wire 6: all four
store/* routes require it and answer agent_configuration_error when
the backend lacks it. The built-in file and Postgres backends support
these capabilities. A custom HTTP service must reproduce their declared
semantics and failures, including attribution and scope allowances;
returning the same JSON shapes alone is insufficient.
The reference backends also advertise "memory_search": {"version": 1}.
This optional, separately versioned operation is independent of legacy
pageable. Clients check it before calling memory/search; absence means
unsupported, and the binary refuses search without downloading documents as
a fallback. Existing wire-6 routes, stored formats and schema 4 are unchanged.
The conformance runner exercises declared search support on both full and
base profiles, and reports skipped search cases when it is absent.
The optional "knowledge_policy": {"version": 1} capability adds governance
filtering to that operation. It requires memory-search version 1. The reference
backends advertise both; clients refuse filtered search on an older service.
Every request but /health carries Authorization: Bearer <token>,
compared against NEOSIAN_SERVE_TOKEN: one token, or a table
actor=token,actor=token so the process records who wrote
(neosian docs topology, “Who wrote what”). A miss is a plain 401
with WWW-Authenticate: Bearer and no JSON body.
An actor may carry an allowance, client:alice@user:alice+alice-=tok:
two literal prefixes matched with startswith against the scope and
conversation_id a request names, and against every id in a search’s
or a trail read’s conversations list. A request outside them is 403
in the envelope under the code forbidden. A constrained token is
refused /mcp and all four store/* routes outright, whatever its body
names, and a conversation search or a trail read that names no conversations (the
whole store) the same way.
A token with no allowance reaches everything unless the opt-in grants below
narrow it. Ignored body fields cannot authorize an unbounded search or trail
read: those routes check the actual conversations array.
Exact-scope grants for portable profiles
Section titled “Exact-scope grants for portable profiles”Set NEOSIAN_SERVE_GRANTS to an operator-owned TOML file. Version 1 grants
intersect the existing token table; missing actors or permissions deny access.
Token values stay exclusively in NEOSIAN_SERVE_TOKEN. For example, with
an authenticated client:reader token actor:
grant_format = 1[[clients]]actor = "client:reader"[[clients.scopes]]scope = "workspace:team"read = truewrite = false[[clients.conversations]]prefix = "w-11111111222243338444555555555555-"read = trueappend = falseMemory scopes match exactly, unlike legacy token prefixes. A conversation
grant requires either a literal prefix or nonempty ids = [...]. read
permits reads, memory search, versions, listings and history; write permits memory writes,
delete and rename; append permits both turns and projections. Permissions
are independent and default false. Erasure also requires operator = true
on the client entry. That flag grants all four whole-store routes, including
unrestricted enumeration and restore subject to legacy allowances; reserve
it for trusted administrators. Non-operators cannot use those routes.
Even an operator’s conversation search/trail request names an explicitly authorized
conversations array. [] remains an empty search, never a global read.
Checks precede store access and apply to raw HTTP callers as well as profile clients. Editing a profile or forging the request’s attribution cannot widen the token’s permission. Grant files reject unknown fields, unsupported format, duplicate actors/scopes and actors absent from the token table before opening the store. Files are bounded to 1 MiB and loaded once at startup. Restart the server after changing grants or revoking/rotating tokens.
With grants enabled the handshake adds this separately versioned optional
capability: "authorization": {"version": 1, "mode": "scoped"}. It reports
enforcement, not a permission inventory. Portable HTTP profiles require it
and refuse an older/unscoped backend. Legacy clients keep wire 6. The profile
and grants schemas print with neosian schema profile and neosian schema grants. Custom services advertising this capability must enforce the same
restrictions; the existing base/full storage runner alone does not certify
authorization. Reference-server adversarial tests are in
tests/contract/test_profile_http.py.
All /mcp access is refused when grants are enabled. Use the local
neosian mcp --profile NAME stdio bridge to the granted storage routes.
Requests
Section titled “Requests”Every route is a POST with a JSON object body; the parameters are the
operation’s, by name. A body that is not JSON, or not an object, is
400. An absent optional parameter and null are the same thing.
Every parameter is typed at the door, nested objects included: a string
where an integer belongs is 400 naming the parameter, and a JSON
boolean is never an integer. Keys the route does not know are ignored.
The optional memory/search v1 request instead rejects unknown fields and
mistyped values. Its defaults apply only when a field is absent; only its
cursor accepts null. This does not change legacy route decoders.
The Content-Type header is not read. A body over 64 MiB is 413,
checked inside the bearer gate, so an unauthenticated oversize body is
still 401.
The envelope
Section titled “The envelope”A refusal is one shape at 400, 403 or 413:
{"error": {"code": "memory_conflict", "message": "Memory conflict on 'notes/a' in scope 'user:a': version_mismatch", "details": {"scope": "user:a", "path": "notes/a", "reason": "version_mismatch", "expected_version": 5, "actual_version": 1}}}code is the stable error identifier and details its typed fields.
neosian schema errors lists active and reserved identifiers.
Two codes exist only on the wire: value_error, for a mis-typed or
missing parameter and for invalid store arguments (an empty message
list, a negative after), and forbidden, for the allowance. A
malformed nested object (a message without role, an archive document
without actor) is value_error with the message
malformed request: 'role'. A 500 carries no envelope and is the
driver’s problem, never a store answer.
The routes
Section titled “The routes”A response is the return value under one key; a listing adds its
continuation beside it. req marks a required parameter.
Memory
Section titled “Memory”| route | request | response |
|---|---|---|
memory/read |
scope req, path req |
document (object or null) |
memory/write |
scope, path, content req; actor, expected_version |
document |
memory/delete |
scope, path req; actor |
deleted (bool) |
memory/rename |
scope, src, dst req; actor |
document |
memory/list_documents |
scope req; prefix, cursor, limit |
entries, next_cursor |
memory/versions |
scope, path req; cursor, limit (default 50) |
versions, next_cursor |
memory/redact |
scope req; path, actor |
count (int) |
memory/history |
scope req; since, cursor, limit |
versions, next_cursor |
memory/redactions |
scope req; since, cursor, limit |
redactions, next_cursor |
memory/changes (optional v1) |
scope req; cursor, limit (1..100, default 50) |
Stream, events, checkpoint/head/floor cursors and explicit gap |
memory/prune_changes (optional v1) |
scope, through req; write (default false) |
format, scope, removed, write, earliest_cursor |
memory/search (optional v1) |
scope, query req; prefix, fields, metadata_keys, limit, cursor |
hits, next_cursor |
A write with no actor is recorded under the token’s client
(client:default); with actor: "me" under client:default/me.
redact with no path redacts the whole scope and records a redaction
whose path is null. versions and history answer newest first.
Memory search version 1
Section titled “Memory search version 1”POST /v1/memory/search requires one explicit authorized scope. Token
allowances and exact-scope read grants are checked before scanning or paging;
an actor label, alternate scope array or cursor cannot widen that scope.
Query parameters and JSON schemas are in the OpenAPI artifact. For example:
{"scope":"workspace:team","query":"deployment window","prefix":"notes/", "fields":["path","content"],"metadata_keys":[],"limit":20,"cursor":null}The 1..16 whitespace terms (1..1024 characters total) must all match selected
values as substrings after per-scalar Unicode 17.0.0 lowercase. White_Space
uses that Unicode version too. No normalization, accent removal, stemming or
regex is applied. path and content are the default fields. Selecting
metadata requires 1..8 explicit top-level extra keys (1..128 characters
each); only their recursive string values participate, never object keys or
numeric/boolean/null values. Unselected metadata is not returned. A prefix
is a case-sensitive literal document prefix, at most 1024 UTF-8 bytes.
Hits contain scope, path, version, UTC created_at/updated_at, actor,
matched_fields and one excerpt. The excerpt has field, nullable
metadata_key, a zero-based Unicode-character offset within the selected
string, and text of at most 240 characters. It starts up to 40 characters
before the first match, preferring content, then selected metadata, then path.
Metadata objects traverse keys in ascending order; arrays retain their order.
Fetch the current authoritative document for its full meaning and version.
Paths sort ascending with bytewise ASCII ordering. Limit defaults to 20 and
must be 1..100. A nonfinal page has exactly limit hits and an opaque
next_cursor (1..4096 bytes); the final page has null. Bind cursors to the
scope, query, prefix, fields and metadata keys; reuse with a different target
or query is value_error. Limit may change. Cursors carry no read authority.
There is no total count or cross-page snapshot. Each page reads current data;
new/renamed paths before the last key require restarting the query.
Only current, non-redacted documents participate. Old versions, deleted
documents, sessions/, handoff, messages/ and message-links/ are excluded
even when a path or retained metadata matches. Reference file search holds
the root lock and Postgres uses one statement snapshot; neither creates a
search index or cache. A completed erasure therefore affects the next query
even though it may preserve version, timestamps and metadata. Output limits
do not bound scan work. neosian docs memory describes the mounted CLI/MCP
forms, which add source references and preserve backend cursor opacity.
With knowledge-policy capability version 1, knowledge_policy accepts all
(omitted default), effective or approved. Filter current governance state
before matching, excerpts and lookahead, at one server UTC instant per scan.
Effective includes unmanaged documents and effective approved content; approved
requires valid content-bound approval. Draft, superseded, expired, scheduled,
stale and invalid governed content is excluded. Cursors also bind this policy.
Scope grants still authorize every page; policy is a selection, not access
authority. Raw reads/history remain unchanged. neosian docs governance and
schema governance specify metadata, approval hashes and time boundaries.
Conversation
Section titled “Conversation”| route | request | response |
|---|---|---|
conversation/append_turn |
conversation_id, messages req; actor |
turn |
conversation/read_turns |
conversation_id req; after, limit |
turns, next_after |
conversation/last_turn_number |
conversation_id req |
turn (int, 0 when none) |
conversation/search_turns |
query req; conversations (a list of ids), limit (default 50, at most 500) |
turns |
conversation/append_projections |
conversation_id, entries req |
{} |
conversation/read_projections |
conversation_id req; after, limit |
entries, next_after |
conversation/redact_turns |
conversation_id req; through, turns (a list of turn numbers), actor |
count (int) |
conversation/turn_redactions |
conversations (a list of ids), since, limit (default 50, at most 500) |
redactions |
search_turns (WIRE_VERSION 5) answers the turns whose text holds
every whitespace-split term of query as a case-insensitive substring,
newest first: a turn’s text is its messages’ text, each tool call as
its name and compact JSON arguments, and its tool results, with no role
labels. conversations absent or null is every conversation the
store holds; a list narrows to those ids and [] answers []. The
answer is bounded and never pages: limit above 500 is value_error
(“a search answers at most 500 turns; narrow the query”), limit: 0
and a blank query are value_error too. Each turn is the
read_turns shape.
redact_turns and turn_redactions (WIRE_VERSION 6) are the eraser
(neosian docs stores). redact_turns blanks the selected turns of one
conversation and every projection entry covering them, keeps each
turn’s number, created_at and actor, and answers the count matched,
already-redacted turns included, so repeating it changes nothing.
through and turns both absent is every turn; through: n is turns 1
to n; turns names the numbers; both given, or a number below 1, is
value_error; an unknown conversation or number matches nothing. The
act is recorded under the token’s client like a write (actor: "ops"
records client:default/ops). turn_redactions answers the trail
newest first and is bounded like a search: limit above 500 is
value_error, conversations narrows as it does for a search, and a
redacted turn never answers a search again. A backend whose erasable
is false answers both routes with agent_configuration_error.
| route | request | response |
|---|---|---|
store/scopes |
{} |
scopes (sorted names) |
store/conversations |
{} |
conversations (sorted ids) |
store/restore_scope |
scope, documents, versions, redactions req |
{} |
store/restore_conversation |
conversation_id, turns, projections req; redactions |
{} |
A restore is verbatim: the rows’ own actors are kept and the caller’s
client is not stamped. The target must be empty, else memory_conflict
or agent_conversation_conflict with reason: "target_occupied". A backend
that cannot be moved whole answers every store/* route with
agent_configuration_error.
Paging
Section titled “Paging”Memory listings take an opaque cursor, cap each page at 500 rows and
answer next_cursor, null on the last page. Cursors are specific to
their listing method; passing a document-list cursor to versions is
value_error. A backend without paging answers whole with
next_cursor: null and refuses a cursor by name; the handshake’s
pageable says which.
Conversation reads take after (a turn number, 0 or absent from the
start). With an omitted limit or a limit above 500 they page automatically
and answer next_after. An explicit limit of 500 or less is a total
bound and answers next_after: null even when more rows exist. Automatic
projection paging ends on a whole turn and can exceed the nominal 500
rows when one turn has more entries; an explicit total bound can cut
that group. On both memory and conversation reads, limit: 0 answers
no rows or continuation; a negative limit or after is value_error.
conversation/search_turns is not a listing and does not page: its
limit is a bound, 500 at most.
The JSON shapes
Section titled “The JSON shapes”Timestamps are ISO-8601 with a Z suffix on the way out; any offset is
accepted on the way in and normalised to UTC; a naive timestamp is
refused.
- document:
scope,path,content,version,created_at,updated_at,actor,redacted,extra(an object,{}when empty). On a restore,extramay be absent; every other key is required. - entry (a listing row):
path,version,created_at,updated_at,redacted. - version (a history row):
path,version,action(created,modified,deleted),content,actor,created_at,redacted. A redacted row keeps its skeleton withcontent: ""andredacted: true. Every key is required on a restore. - redaction:
path(ornullfor a scope),actor,created_at,count. - turn:
conversation_id,turn,messages,created_at,actor(a string ornull; absent on a restore reads asnull),redacted(absent on a restore reads asfalse). A redacted turn carriesmessages: []andredacted: true; no other turn carries an empty message list, and a restore refuses one. - turn redaction (a trail row):
conversation_id,turns(the numbers blanked, ascending integers from 1),actor,created_at. - projection:
turn,kind(log,digest,epoch),text,span(absent reads as1).
A message is the public codec’s shape, the same object the FileStore turn log holds:
{"role": "assistant", "content": "hi", "reasoning": null, "tool_calls": [{"id": "c1", "name": "memory", "arguments": {"command": "view"}}], "tool_call_id": null}role is the one required key; content is a string, null, or a
list of blocks ({"type": "text", "text": ...}, and image,
document and compaction blocks with their own keys, an unknown
type refused). A tool call’s id and name are strings and its
arguments an object, absent reading as {}; a string of JSON there
is refused. extra on a message or a tool call is carried verbatim and
omitted from the answer when empty. Keys the codec does not know are
dropped, not stored.
WIRE_VERSION
Section titled “WIRE_VERSION”| version | release | what changed |
|---|---|---|
| 1 | 0.80.0 | the process: twelve routes, the handshake, the envelope |
| 2 | 0.83.0 | memory/history, memory/redactions, the turn actor, the handshake’s client |
| 3 | 0.89.0 | the four store/* routes |
| 4 | 1.0.0rc8 | every listing answers a page; the handshake’s pageable |
| 5 | 1.0.0rc20 | conversation/search_turns; a constrained token’s conversations list |
| 6 | 1.8.0 | conversation/redact_turns, conversation/turn_redactions; the turn’s redacted; the archive’s redactions; the handshake’s erasable |
A narrowing of accepted legacy input does not move the wire version. Routes and required response changes need a wire revision. The optional authorization capability introduced with profiles is a recorded, separately versioned extension; base handshakes and all stored shapes remain unchanged. The binary refuses a different advertised wire version before any storage call.
Beside the wire
Section titled “Beside the wire”/mcp is available when the process starts with mounts. It uses authenticated
streamable HTTP and the initialize/initialized handshake through protocol
2025-11-25. The retired Python server’s 2026 single-exchange protocol is not
part of v2. See neosian docs mcp for lifecycle and tool scope boundaries.
Knowledge bundles and collection metadata are format-1 application content
over existing memory routes. They add no wire operation or backend capability.
The scoped CLI composes those reads/writes; it cannot claim a transaction or
atomic compare-and-create across them. neosian docs knowledge distinguishes
current-document ingestion from whole-store restoration.
Skill management reuses existing mounted memory read/write/list/version operations. Skills, resources and their metadata remain normal documents; profile revision guards need no additional storage capability. The binary checks current dependencies/resources before serving managed instructions.
Reviewed upkeep also uses those existing routes, plus ordinary deletes. Its
proposal format adds no backend capability or DDL. Replacement uses
expected_version; wire 6 has no conditional delete, redaction-generation
compare-and-swap or multi-document transaction. Snapshot rechecks cannot
close every concurrent-write window. Apply/recovery therefore require paused
writers, and lost acknowledgements require current-state inspection before
preparing further work. See neosian docs memory.
Optional change-feed version 1
Section titled “Optional change-feed version 1”change_feed: {version: 1} advertises durable per-scope state references and
explicit bounded replay. FileStore supports it; Postgres requires the separate
operator-installed changes extension 1 on schema 4. Clients refuse absent or
unsupported declarations. changes-v1.schema.json specifies events and pages;
neosian docs changes specifies ordering, atomicity, resets and retention.
memory/changes requires current scope read authority before storage access.
memory/prune_changes, including preview, requires operator and scope write
authority. Profile policies do not hide authorized raw audit references. A cursor
is scoped, opaque, at most 2,048 UTF-8 bytes and never grants authority. Unknown
fields and malformed requests refuse. Normal empty pages retain a checkpoint;
gaps are untracked, retention or stream_reset, never successful omission.
Events are ordered by sequence and contain references, never document bodies.
A custom backend must durably couple its mutations and events before advertising
this capability. Independently passing JSON shape validation is insufficient.
The CLI exchange format 1 composes existing scoped memory reads and change-feed
v1 into snapshots and deltas for an explicit local receiver. There is no new
HTTP capability or apply route. Current grants apply to every source read;
source histories and raw turns are excluded from the output.
neosian schema exchange and neosian docs exchange specify that separate
contract, paused-source requirement, local recovery and conflict rules.
Hear about it first: [email protected]· Apache-2.0 · a Mausa AI project · neosian.com