SSyncropel Docs

API Reference

HTTP endpoints for the Syncropel server.

Work-loop paths + error codes renamed

The work-loop HTTP routes moved from /v1/agent/* to /v1/work/*, and the corresponding error codes changed from AGENT_LOOP_* to WORK_* (for example, AGENT_LOOP_NO_PROVIDER → WORK_NO_PROVIDER).

The old paths return 404 — clients hitting /v1/agent/loop or related URLs should switch to the new ones below.

Base URL

http://localhost:9100

The server listens on port 9100 by default. All endpoints return JSON.

Authentication

When auth.required = true (the default), every /v1/* endpoint requires a bearer token:

Authorization: Bearer spl_<env>_<sa_id>_<secret>

Tokens are minted against service accounts with a closed scope list. The middleware validates the token, checks the requested endpoint's required scope, and verifies the claimed actor DID (X-Syncropel-Actor) is in the SA's allowed-actors list.

The spl CLI resolves bearer tokens in this precedence: --token <value> flag first, then the SPL_TOKEN environment variable, then ~/.syncro/token. HTTP callers pass the token directly in the Authorization header.

Exempt routes (unauthenticated even when auth is enforced):

  • GET /health
  • POST /v1/bootstrap/service-account (one-shot per namespace)
  • GET /.well-known/syncropel
  • POST /v1/recredential (the proof in the body is the credential; constant-shape refusal, rate limited; see Self)

For the full model — scopes, token format, rotation, federation composition — see the Authentication & Service Accounts guide. Endpoints for managing service accounts and tokens are documented in the Service accounts section below.

Health

MethodPathDescription
GET/healthHealth check — returns {"status": "ok", "version", "instance_did", "proof_kinds_accepted"}. proof_kinds_accepted lists which re-credential proofs this instance takes (recovery_code, and account or passkey once declared), and passkey_rp: {rp_id} names the relying party when one is declared, so a client can tell before authenticating.

Records

MethodPathDescription
POST/v1/recordsIngest a new record
GET/v1/records/:idGet a record by ID
GET/v1/records?thread=XList records filtered by thread
GET/v1/records?actor=XList records filtered by actor
POST/v1/records/queryRich query — MongoDB-style filter document over the log
POST/v1/records/searchSemantic search — rank records by cosine similarity to an embedded query
POST/v1/records/embedBackfill embeddings for records that don't yet have one under the active provider

Find

Blended text search — file names, file contents, and conversations in one ranked result set. No embedding provider required. See the Find guide for hit shapes and examples.

MethodPathDescription
GET/v1/find?q=XBlended search. Params: limit (≤100), types (material|thread), explain, prefix (type-ahead — last word matches as a prefix)
GET/v1/search/statsIndex health: coverage, pending items, last indexing failure
POST/v1/search/rebuildDrop + incrementally re-index. Body: {"scope": "materials" | "threads" | "all"}

Ingest a Record

curl -X POST http://localhost:9100/v1/records \
  -H "Content-Type: application/json" \
  -d '{
    "act": "INTEND",
    "actor": "did:sync:user:alice",
    "thread": "th_abc123...",
    "body": {"goal": "Deploy the service"},
    "clock": 0,
    "data_type": "SCALAR"
  }'

Namespace-scoped ingest

Records can target a specific namespace by setting body.namespace. The instance walks the namespace's ancestor chain and rejects the record with 403 NAMESPACE_REJECTED if any ancestor is missing or not Active. Records that omit body.namespace fall through to the implicit default namespace and are always accepted.

curl -X POST http://localhost:9100/v1/records \
  -H "Content-Type: application/json" \
  -d '{
    "act": "INTEND",
    "actor": "did:sync:user:alice",
    "thread": "th_abc123...",
    "body": {
      "namespace": "acme-corp/payments/staging",
      "goal": "Deploy v2.3 to staging"
    },
    "clock": 0,
    "data_type": "SCALAR"
  }'

If the namespace does not exist, the response is:

{
  "object": "error",
  "type": "invalid_request_error",
  "code": "NAMESPACE_REJECTED",
  "message": "namespace 'acme-corp/payments/staging' rejected: ancestor 'acme-corp/payments' is not Active in the registry. Create it first with `spl namespace create acme-corp/payments`."
}

The error always names the failing ancestor and includes the exact recovery command. To declare namespaces use the spl namespace CLI commands, which write to the well-known thread th_namespace_registry that the engine hot-reloads on every change.

Rich query

POST /v1/records/query runs a structured filter over the record log server-side. The body is the filter AST — top-level keys are AND-combined, values are either scalars (sugar for $eq) or operator documents ($in, $gt, $like, $regex, $and, $or, $not, …). Body fields are addressed with dot-paths (body.kind, body.priority, body._refs.track.id). See the query guide for the full grammar, operator semantics, and index-awareness.

curl -s -X POST http://localhost:9100/v1/records/query \
  -H "Authorization: Bearer $SPL_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "filter": {
      "act": "INTEND",
      "actor": "did:sync:agent:dev",
      "body.kind": { "$in": ["core.task.record", "core.task.record.v1"] }
    },
    "sort": { "clock": -1 },
    "limit": 20
  }'

Request body:

FieldTypeDefaultDescription
threadstring—Optional thread scope. Shorthand for adding {"thread": "..."} to the filter; lets the backend use the thread index
filterobject{}MongoDB-style filter document
sortobject—Single-key sort spec: {"clock": -1} or {"created_at": 1}
limitint100Result cap. Clamped to 1000: asking for more returns 1000 and capped: true
offsetint0Pagination offset
explainboolfalseWhen true, response includes a plan block describing the translated SQL, bind count, and which fields used the indexed fast path vs. json_extract

Two response fields are easy to misread. capped: true means more disclosable rows may exist beyond this page, whatever limit you asked for; it is the only truthful signal that a page is partial.

matched_total is disclosure-aware, and has been since v0.174. It was once the store's count taken before the disclosure filter ran, which made it a measured existence oracle: a narrowed caller could ask about a name it was not allowed to see and learn from the denominator whether anything by that name existed, while data came back empty. It is now served only when nothing was withheld from you; when something was, you are told how many rows you may see instead. An unrestricted reader is unaffected.

So matched_total is a count over your own view, not over the instance. Two callers with different grants can both receive honest and different totals for the same filter, and neither number is evidence about what the other can see. It is still not a substitute for capped when deciding whether you have the whole answer.

Response:

{
  "object": "list",
  "data": [
    {
      "object": "record",
      "id": "7a2936eccf6b...",
      "parents": [],
      "thread": "th_abc...",
      "actor": "did:sync:agent:dev",
      "act": "INTEND",
      "body": { "kind": "core.task.record.v1", "...": "..." },
      "clock": 42,
      "data_type": "SCALAR"
    }
  ],
  "plan": {
    "sql": "SELECT ... WHERE act = ? AND actor = ? ...",
    "bind_count": 3,
    "indexed_fields": ["act", "actor", "thread"],
    "unindexed_fields": ["body.kind"]
  }
}

The plan block is only present when explain=true. Results are always tenant-filtered — records outside the caller's namespace never appear.

Error codes:

CodeNameMeaning
400INVALID_QUERYUnknown operator, unknown field path, type mismatch, malformed filter
500INTERNALStorage backend failure

Index-backed fast paths depend on the indexed field registry — declare indexed body.<field> paths via spl config add-body-kind-manifest so predicates on them use CREATE INDEX expressions instead of scanning JSON.

POST /v1/records/search embeds the query through the configured provider, ranks records by cosine similarity, and returns the top K. Envelope filters (thread, actor, kind, after_clock) narrow the result after ranking so near-misses outside the scope don't crowd out the best answer. See the semantic search guide for provider setup, CLI usage, and SDK helpers.

curl -s -X POST http://localhost:9100/v1/records/search \
  -H "Authorization: Bearer $SPL_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "query": "authentication failure logs",
    "k": 5,
    "thread": "th_incident_42"
  }'

Request body:

FieldTypeDefaultDescription
querystring—Free-text query. Must be non-empty
kint10Top-K to return. Clamped to [1, 100]
threadstring—Restrict to records on this thread
actorstring—Restrict to records emitted by this actor DID
kindstring—Restrict to records whose body.kind matches
after_clockint—Restrict to records with clock > N

Response:

{
  "object": "list",
  "embedder": "ollama:nomic-embed-text",
  "k": 5,
  "data": [
    {
      "object": "record",
      "id": "3f4a...",
      "score": 0.847,
      "parents": [],
      "thread": "th_incident_42",
      "actor": "did:sync:agent:ops",
      "act": "KNOW",
      "body": { "...": "..." },
      "clock": 118,
      "data_type": "SCALAR"
    }
  ]
}

Results are tenant-filtered after scoring — a record outside the caller's namespace never reaches the response, regardless of score.

Error codes:

CodeNameMeaning
400EMPTY_QUERYquery was absent or blank
502EMBEDDER_FAILEDThe configured provider rejected the embedding request
503SEMANTIC_SEARCH_DISABLEDNo embedding provider is configured — see the embedding_provider topic

Backfill embeddings

POST /v1/records/embed walks records that don't yet have an embedding for the active provider and embeds them synchronously within the request. Use this once after enabling a provider to backfill existing archives; new records are embedded inline by the INGEST loop.

curl -s -X POST http://localhost:9100/v1/records/embed \
  -H "Authorization: Bearer $SPL_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"limit": 100}'

limit defaults to 100, max 500. Response: {embedder, embedded, skipped_empty, failed, remaining, has_more}. Loop until has_more is false — or drive the CLI wrapper spl embed --loop.

Threads

MethodPathDescription
GET/v1/threadsList all threads
GET/v1/threads/:id/recordsGet all records on a thread
GET/v1/threads/:id/stateGet folded thread state
GET/v1/threads/:id/projectGet thread projection (formatted view)
GET/v1/threads/:id/participantsGet thread participants

Thread Projections

curl "http://localhost:9100/v1/threads/th_abc123.../project?format=tui"

Projection formats:

FormatDescription
tuiTurn-based view for terminal display
messagesChat-style message format

Data plane (files & blobs)

The data plane is a per-namespace virtual filesystem. The control surface (/v1/data/*) manages paths and metadata; the byte surface (/v1/blobs/*) moves content. The TypeScript SDK wraps all of this as client.data.*.

MethodPathDescription
GET/v1/data/list?path=List a directory
GET/v1/data/stat?path=Node metadata (size, hash, content type, pin state, provenance)
GET/v1/data/materials?q=&limit=Search materials (metadata, no bytes)
GET/v1/data/node?path=Resolve one material node
POST/v1/data/mkdirCreate a directory ({ path })
POST/v1/data/mvMove/rename ({ from, to })
POST/v1/data/rmRemove ({ path, recursive? })
POST/v1/data/write/initBegin a write ({ path, size_bytes, content_type?, precondition? }) → { upload_id, chunk_size }
PUT/v1/blobs/upload/:upload_idUpload a chunk (body = bytes, header Content-Range: bytes <start>-<end>/<total>)
POST/v1/data/write/completeCommit the write ({ upload_id }) → { content_hash, size_bytes }
POST/v1/data/read-urlResolve a path to a blob URL ({ path }) → { blob_url, expires_in, via }
GET/v1/blobs/:hashFetch content bytes by hash
POST/v1/data/publishPromote a file to a durable artifact ({ path, storage_class? })
GET/v1/data/usageNamespace storage usage + quota
GET/v1/data/capabilitiesData-plane limits (chunk size, version)
GET/v1/data/provenanceClock-ordered "made by" feed

Writing a file

A write is three steps: initiate, upload (chunked), commit. Pass precondition.expected_hash for optimistic concurrency — a string for compare-and-swap, null for create-only; omit it for last-writer-wins. A mismatch returns 409 with { expected_hash, current_hash }.

# 1. initiate
curl -X POST http://localhost:9100/v1/data/write/init \
  -H "content-type: application/json" \
  -d '{"path":"/files/notes.md","size_bytes":12,"content_type":"text/markdown"}'
# → { "ok": true, "upload_id": "up_...", "chunk_size": 8388608 }

# 2. upload the bytes
curl -X PUT http://localhost:9100/v1/blobs/upload/up_... \
  -H "content-range: bytes 0-11/12" --data-binary "# hello mom"

# 3. commit
curl -X POST http://localhost:9100/v1/data/write/complete \
  -H "content-type: application/json" -d '{"upload_id":"up_..."}'
# → { "ok": true, "content_hash": "9565f5...", "size_bytes": 12 }

Folds

A fold is a derived view computed from records — the turn timeline, thread state, trust, and more. One endpoint resolves any fold; Syncropel dispatches by name.

MethodPathDescription
GET/v1/folds/:nameResolve a global fold (e.g. trust, task_list)
GET/v1/folds/:name/:keyResolve a per-thread fold (key = thread id; e.g. turn, thread_state)
GET/v1/folds/frame/:threadThe renderable frame for a thread
GET/v1/folds/rollupA digest across everything you reach (pass ?root=⊤ for your whole reach)
GET/v1/folds/principal_trustTrust per person, unified across their instances (?root=⊤)
curl "http://localhost:9100/v1/folds/turn/th_abc123..."
# → { "fold_name": "turn", "cache_key": "th_abc123...", "watermark": 42, "value": [ ... ] }

Each response carries a watermark (max(records.sequence) the value covers) so a client can cache and refetch only when it moves.

Graph

Graph queries traverse the record graph of people, threads, and the records that connect them. Pass ?root=⊤ to query across everything you reach (see Universal traversal).

MethodPathDescription
GET/v1/graph/query?op=path&from=&to=Shortest path between two nodes
GET/v1/graph/query?op=shared_context&a=&b=Threads two people share
GET/v1/graph/query?op=neighborhood&node=&k=The k-hop neighborhood of a node
GET/v1/graph/query?op=centralityMost-connected nodes in reach
GET/v1/graph/facetsThe facets (kinds, people, threads) you can see
curl "http://localhost:9100/v1/graph/query?op=shared_context&a=did:sync:user:alice&b=did:sync:user:bob"

Trust

MethodPathDescription
GET/v1/trustGet all trust scores

Actors

MethodPathDescription
GET/v1/actorsList registered actors
GET/v1/actors/lookup?did=XGet actor detail by DID
GET/v1/actors/rosterEvery defined actor with its definition record id, adapter kind, enabled state, model, tools, prompt reference, who proposed it, and trust standing per domain ({object: actor_roster, count, data: [...]}).
GET/v1/runs?state=&actor=&since=&limit=Every run across actors, newest first, filtered by state, principal or executor, and start time; a bounded scan with capped on the wire.
GET/v1/actors/{did}/usageThe actor's whole daily usage fold: loop count, input and output tokens, cost in USD for today's window ({object: actor_usage, ...}). Zeros when the actor has not run today.
GET/v1/runs/{thread}/provenanceThe definition, prompt and frame records a run row names, served under the run row's own read posture, so a run's participant can read the exact words their run ran under.
POST/v1/blueprints/materializeInstall a blueprint. Body: {blueprint, extends?[], regime?, accept_plan?}. The instance authors the records; the caller's own grant bounds any actor the blueprint declares (403 TOOL_NOT_IN_REACH / WRITE_KIND_NOT_ADMITTED); a signed blueprint needs the reviewed plan hash (428 PLAN_CONSENT_REQUIRED); an unsigned or untrusted one is 403 BLUEPRINT_UNSIGNED.

A run row (GET /v1/runs/{thread}) carries definition (the actor definition record the run executed under), prompt (the prompt record it resolved at start), frame (the record of the words the kernel itself told the run before its charter), narrowed (granted tools the transport withheld), transport, and the tool-call tally (tool_calls_ok, tool_calls_failed). On the run's thread every turn carries resolution_path and every tool result duration_ms; a provider failure inside the run is a record of its own (core.work.provider_error.v1, typed error on the event stream), beside the typed tool.verdict and guardrail events. A definition whose prompt does not resolve refuses the start with 422 WORK_PROMPT_UNRESOLVED, naming the record; a prompt reference that is not a record is refused at define with the same code. The define door refuses work_loop.reach and admit (400 DEFINE_FIELD_INERT): a run's reach is its tier's, a read bound is a grant's. brief and stands_on land on the definition. An open ask on the row names its proposal_kind when it is a proposal.

Approving a decision whose context is a blueprint proposal (POST /v1/decisions/{id}/decide with {"decision": "approve"}) installs the blueprint as the approver and answers with kind: blueprint_proposal and installed (the actors, their derived grants, the records written). A plan-hash mismatch between the request and its blueprint is 409 PLAN_HASH_MISMATCH; the approver's grant bounds the actor exactly as the install door does. A verdict on a run under a proposed definition by the actor that proposed it is refused, 400 VERDICT_SELF_JUDGMENT.

Dispatch

MethodPathDescription
POST/v1/dispatchDispatch work to an actor

Dispatch Work

curl -X POST http://localhost:9100/v1/dispatch \
  -H "Content-Type: application/json" \
  -d '{
    "actor_did": "did:sync:agent:dev",
    "goal": "Fix the authentication bug",
    "thread_id": "th_abc123...",
    "budget": {"max_cost_usd": 1.0, "max_duration_secs": 600}
  }'

Work loops

MethodPathDescription
POST/v1/work/loopStart a work loop. Body: {goal, max_turns?, token_budget?, wall_clock_secs?, workspace?}. Returns the loop's thread id.
POST/v1/work/loop/previewCost preview — returns effective tier caps, usage so far today, would_be_denied, and denial_reason without starting a loop.
GET/v1/work/loop/{thread}Loop status for the given thread.
POST/v1/work/loop/{thread}/cancelCancel a running loop. Emits a core.work.loop_cancel.v1 DO record; the reconciler propagates the cancel to the in-process loop.
GET/v1/work/loop/{thread}/streamSSE live progress for the given loop thread. Alias for the thread-watch handler — the loop thread itself is the filter.
GET/v1/work/compensation/reviewPer-actor compensation review (operator tier only). Returns total compensations + breakdown by seam + breakdown by violated assumption + anomaly score vs. fleet median.
GET/v1/work/compensation/review/topTop anomalous actors by compensation rate (operator tier only).

Start a loop

curl -X POST http://localhost:9100/v1/work/loop \
  -H "Authorization: Bearer $SPL_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "goal": "Summarise this week of dispatches",
    "max_turns": 16
  }'

The response shape:

{
  "object": "work_loop",
  "status": "started",
  "thread": "th_...",
  "tier": "operator",
  "effective_config": {
    "max_turns": 16,
    "token_budget": null,
    "wall_clock_secs": null,
    "forbidden_tools": ["bash", "write_file"]
  },
  "watch": "/v1/threads/.../records"
}

The thread id is the loop id (there is no separate loop_id field). tier is the actor's resolved tier. effective_config carries the resource ceilings the loop will run under — combining the tier's daily limits with any per-loop task_budget you sent. watch is the SSE stream URL for live progress.

Cost preview before starting

curl -X POST http://localhost:9100/v1/work/loop/preview \
  -H "Authorization: Bearer $SPL_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"goal": "..."}'

Returns the actor's effective tier (Trial / Paid / Team / Operator), the tier caps (max_turns, wall_clock_secs, token_budget, daily_cost_cap_usd, daily_loop_cap), usage so far today (loop_count, cost_usd), estimated_cost_usd, the tool_grant shape (default and requestable tool sets), and — if the request would exceed a cap — would_be_denied: true with denial_reason set to WORK_DAILY_CAP_EXCEEDED or WORK_DAILY_COUNT_EXCEEDED.

Watch live progress over SSE

curl -N http://localhost:9100/v1/work/loop/th_abc123.../stream \
  -H "Authorization: Bearer $SPL_TOKEN"

Emits the loop's thread records as they land — core.work.turn.v1, core.work.tool_call.v1, core.work.tool_result.v1, and ultimately core.work.loop_outcome.v1.

Cancel a loop

curl -X POST http://localhost:9100/v1/work/loop/th_abc123.../cancel \
  -H "Authorization: Bearer $SPL_TOKEN"

This emits a core.work.loop_cancel.v1 DO record on the loop's thread; the reconciler propagates the cancel to the in-process loop, which exits with a cancelled outcome.

Decisions

MethodPathDescription
GET/v1/decisionsList pending decisions
POST/v1/decisions/:id/decideAnswer a pending decision: pick an option, answer in your own words, or pick several

Answer a Decision

An ask an actor raises offers its readings as options, each with a label, a one-line description, and one marked recommended. Pick one by its id:

curl -X POST http://localhost:9100/v1/decisions/abc123.../decide \
  -H "Content-Type: application/json" \
  -d '{"decision": "newest", "reason": "Recent work first"}'

When the ask says allow_other: true, answer in your own words instead:

curl -X POST http://localhost:9100/v1/decisions/abc123.../decide \
  -H "Content-Type: application/json" \
  -d '{"decision": "other", "provided_value": "Do both, newest first"}'

When the ask says multi: true, pick several with "decisions": ["newest", "played"]. A choice the ask does not allow is refused by name: BAD_OPTION, OTHER_NOT_ALLOWED, MULTI_NOT_ALLOWED, or PROVIDED_VALUE_REQUIRED. An older ask with only approve and reject answers exactly as before. The waiting run resumes on your answer as words, the chosen reading's label and description or your own sentence, never on an id.

Engine

MethodPathDescription
GET/v1/engine/healthEngine health counters
GET/v1/engine/expression_cache/statsCEL expression cache statistics (hit rate, size, avg compile time) — used by spl doctor
GET/v1/config/rulesList routing rules
GET/v1/adaptersList registered adapters

Expression cache stats

curl http://localhost:9100/v1/engine/expression_cache/stats

Returns a JSON object with hits, misses, compile_errors, hit_rate_pct, avg_compile_micros, size, capacity. Healthy steady-state: hit rate > 99%, avg compile < 100μs, size much less than capacity (1024 default). spl doctor consumes this endpoint as one of its 7 checks.

Audit

There is no dedicated /v1/audit/* endpoint family. The spl audit export CLI command produces JSONL by querying existing endpoints client-side:

  1. GET /v1/threads → enumerate threads
  2. GET /v1/threads/:id/records → fetch records per thread
  3. Filter client-side by category (system actor, the decision gate verdict, dispatch outcome, governance) and time window
  4. Emit one JSON object per matching record on stdout

This means spl audit export works against any Syncropel instance without server-side changes. The trade-off is that it's O(n) over thread count — for very large stores you should pass --thread <id> to scope the query.

A server-side /v1/audit/export?since=...&categories=... endpoint is planned for a future release. It would push the filtering into SQL and return a single JSONL stream, removing the per-thread round trips. For now, use spl audit export (scoped with --thread <id> for large stores).

For SIEM integration recipes (cron rotation, Splunk/Elastic/Loki pipelines, etc.) see the SIEM Integration guide.

Permission denials are NOT in audit export today

HTTP middleware permission denials are emitted as tracing::warn! events to the instance log (~/.syncro/logs/spl.log), not as records. They appear in the log filtered by grep "PERMISSION DENIED" but do NOT appear in spl audit export output. Promoting denials to first-class audit records is tracked as a follow-up — for now treat the instance log and the audit export as complementary security-event streams.

Proxy

MethodPathDescription
POST/v1/proxy/messagesMessages-API-shape proxy (translates to records)

The proxy endpoint accepts a Messages-API-shape request body and translates it into Syncropel records. Use this to route existing AI tool calls through Syncropel for observability and trust tracking.

Capability discovery

Two endpoints expose what the instance supports. Clients should prefer them over hard-coding feature flags — the manifest is the single source of truth.

MethodPathAuthDescription
GET/v1/capabilitiesauthenticated (optional when auth is off)Client-facing capability manifest
GET/.well-known/syncropelunauthenticatedPublic envelope — capabilities_manifest + federation_manifest as sibling keys

GET /v1/capabilities

The authenticated surface. Returns the capability manifest scoped to the caller — auth state, advertised MCP tools, supported DID methods, store backend, available transports, API version. Clients use this to enable or disable features based on what the connected instance actually supports; SDKs feature-detect without a trial-and-error HTTP round trip.

curl -s -H "Authorization: Bearer $SPL_TOKEN" \
  http://localhost:9100/v1/capabilities | jq

Response (elided):

{
  "object": "capabilities_manifest",
  "manifest_version": "1",
  "daemon": {
    "version": "0.X.Y",
    "api_version": "v1",
    "spec_version": "0.18"
  },
  "auth": { "required": true },
  "did_methods": ["did:sync", "did:key", "did:web"],
  "record": {
    "algebra_version": "v1",
    "hashed_fields": ["parents", "thread", "actor", "act", "body", "clock", "data_type"]
  },
  "mcp_tools": ["find", "create_thread", "emit_record", "read_thread", "trust_query", "fold_thread", "dispatch", "fan_out"],
  "transports": { "...": "..." },
  "semantic_search": { "...": "..." },
  "store_backends": ["sqlite", "memory"],
  "capabilities": {
    "records": true,
    "threads": true,
    "sync": true,
    "federation_consent": true,
    "directory": false,
    "...": "..."
  },
  "conventions": {
    "decisions_path": "/v1/decisions",
    "identity_path": "/v1/identity",
    "sync_path": "/v1/sync",
    "trust_path": "/v1/trust"
  }
}

The capabilities map is a set of boolean feature flags covering the full surface — sync, directory (gated by config.directory_enabled), federation_consent, identity, dispatch_observability, cusum_alerts, trust_detail, reconcile_queue, and more. Flags are additive across releases; absence of a flag means unsupported.

When auth is off, the endpoint is reachable without a token. When auth is on, an unauthenticated call returns 401 AUTH_REQUIRED — use /.well-known/syncropel below for the public view.

GET /.well-known/syncropel

The unauthenticated surface, served at the Well-Known URI location so any peer on the public internet can discover an instance without prior coordination. The response envelope carries two top-level manifests as sibling keys:

KeyAudiencePurpose
capabilities_manifestProspective clientSame shape as /v1/capabilities — a peer can feature-detect before authenticating
federation_manifestProspective federation peerEd25519-signed; describes what this instance offers the federation mesh
curl -s http://localhost:9100/.well-known/syncropel | jq 'keys'
# [ "capabilities_manifest", "federation_manifest" ]

The federation_manifest key is present only when the instance is federation-enabled and has a signing key available. Peers that only want the client view can read capabilities_manifest and ignore the federation sibling.

Federation manifest shape

The federation_manifest is Ed25519-signed over its canonical JSON (excluding the signature field). Verification proceeds against the DID document resolved from instance.did. Consumers — spl discover, federation directories, peer-discovery CLIs — verify the signature before trusting any advertised field.

{
  "object": "federation_manifest",
  "manifest_version": "1",
  "daemon": {
    "did": "did:web:alice.dev",
    "version": "0.X.Y",
    "federation_protocol_version": "0.13"
  },
  "federation": {
    "enabled": true,
    "pair_endpoint": "https://alice.dev/v1/sync",
    "sync_change_endpoint": "https://alice.dev/v1/sync/changes",
    "sync_record_endpoint": "https://alice.dev/v1/sync/records"
  },
  "advertises": {
    "kinds": ["music.catalog.track", "music.catalog.artist"],
    "refs": [],
    "namespaces": ["alice-music"]
  },
  "consent_policy": {
    "default_posture": "pair-then-ask",
    "accepts_pair_requests": true,
    "requires_did_method": [],
    "rejects_did_method": []
  },
  "capabilities_ref": {
    "url": "https://alice.dev/.well-known/syncropel",
    "key": "capabilities_manifest"
  },
  "responders_ref": {
    "url": "https://alice.dev/.well-known/syncropel",
    "key": "responders_manifest"
  },
  "directory": {
    "registered_at": [],
    "directory_registration_id": null
  },
  "signature": {
    "alg": "Ed25519",
    "key_id": "did:web:alice.dev#key-1",
    "signature": "<base64>",
    "signed_at": "2026-04-22T18:00:00Z",
    "expires_at": "2026-04-29T18:00:00Z"
  }
}

Key fields:

FieldPurpose
instance.didThe DID this manifest is authoritative for. Must match the DID served at the domain's DID document
federation.pair_endpointEntry point for the pair handshake
advertises.kindsbody.kind values the instance answers federation queries about. A peer advertising music.catalog.track commits to serving sync requests for that kind per its consent policy
advertises.refsContent-addressed REFERENCE identifiers this instance holds — used by future DHT discovery as the primary announce key
advertises.namespacesNamespace handles whose records are federation-eligible (willingness, not grant)
consent_policy.default_posture"open", "pair-then-ask", "invite-only", or "closed". Informative hint for pre-handshake self-filtering
capabilities_ref / responders_refPointers back into the same envelope; clients follow them to fetch the sibling manifests
signature.expires_atForces periodic re-signing (default TTL 7 days). Expired signatures should be rejected

For the discovery workflow and how this composes with did:web, did:sync, and mDNS discovery, see the federation discovery guide.

Identity

MethodPathDescription
GET/v1/identityThe instance's identity, plus the calling token's authentication state

Returns the instance's own identity — {actor, display_name, did, method, key_path, key_fingerprint} — populated at instance startup. New installs generate a did:key:... on spl init; upgraded installs without a DID need spl init --force once.

This endpoint requires a valid bearer token. Since v0.53 the response also reports the calling token's own authentication state, so a renderer can decide what to show the viewer:

FieldDescription
is_authenticatedtrue for any request that reached this endpoint with a valid token.
scopesThe token's resolved capability scopes (admin is the catch-all).
service_account_idThe service account behind the token, or null for a key-based identity.
expires_atWhen the token expires, or null if it does not.

The anonymous form of this block — is_authenticated: false, scopes: [] — is what an unauthenticated caller sees inside the bootstrap aggregate below.

Self

The doors behind Keeping your access. All /v1/self* routes are scope-exempt and credential-required: any live bearer reaches them, including a guest bearer with an empty scope set, because they answer about the credential's own principal and nothing else.

MethodPathDescription
GET/v1/selfWho this credential is: label, standing, bindings ({recovery_code, passkey, account} as booleans) and proof_kinds_accepted
POST/v1/self/bindingsBind a proof to the caller's own principal. Body {"kind": "recovery_code"} answers {code_id, principal, kind, secret}; the secret is 26 Crockford base32 characters, shown once, stored only as a hash. A second binding of the same kind is a rotation and needs rotation_proof (the current code); without it the door answers 409 ROTATION_REQUIRES_PROOF. {"kind": "account", "material": {"subject"}} binds a hosted-account subject once the instance declares a verifier.
GET/v1/self/bindingsThe caller's live bindings: {data: [{id, kind, created_at}]}, never the material
DELETE/v1/self/bindings/{id}Remove one of the caller's own bindings
GET/v1/self/credentialsEvery live bearer that opens this instance as the caller: {data: [{bearer_id, sa_id, expires_at, last_used_at, via, current}]}
POST/v1/self/bindings/challenge{"kind": "passkey"} answers the registration challenge: {challenge, rp: {id, name}, user: {id, name, display_name}, expires_at}; the browser's registration (attestation none, discoverable credential, no extensions) is then bound with {"kind": "passkey", "material": {credential_id, client_data_json, attestation_object, transports?}}. A second passkey needs rotation_proof (the current recovery code, or a passkey assertion over a Door 1 challenge).
DELETE/v1/self/credentials/{bearer_id}Revoke one bearer. It fails its next request with 401; the caller's other bearers and the grant are untouched. Not-yours and not-exists answer the same 404 CREDENTIAL_NOT_FOUND.

Re-credential

For a passkey, first POST /v1/recredential/challenge with {principal, kind: "passkey"} (no bearer): it answers {challenge, rp_id, expires_at} in the same shape for every principal string, 409 PASSKEY_NOT_ACCEPTED when the instance declares no relying party, and 503 LEASE_HELD_ELSEWHERE on a follower; then the assertion goes in evidence as {credential_id, client_data_json, authenticator_data, signature, user_handle?}.

curl -X POST https://alice.syncropel.app/v1/recredential \
  -H "Content-Type: application/json" \
  -d '{"principal": "did:sync:user:guest:aa72…", "proof": {"kind": "recovery_code", "evidence": "KRMRWPEERZRHMSWAH8Y91V9S7K"}}'

No bearer: the proof is the credential. On success: {token, principal, expires_at, label, sa_id, scopes, rebind_required}. The new bearer is parented to the principal's existing live grant; nothing is renewed, revoked, or cascaded, so the person's other devices keep working. A recovery code is single-use: rebind_required: true tells the client to bind a fresh one at POST /v1/self/bindings with the new bearer. account and passkey proofs are reusable and answer rebind_required: false. A passkey whose sign count goes backwards is refused constant-shape.

Refusals are constant-shape by contract. 401 RECREDENTIAL_REFUSED is the one answer for a principal that does not exist, has no binding, or presented a wrong proof; only after a proof verifies can 409 NO_LIVE_GRANT say that nothing here is granted to that principal any more. 429 RATE_LIMITED (the door is rate-limited per supplied principal string and per address) is answered before any hashing work. Clients render the message verbatim and add no diagnosis, or they become the oracle the door refuses to be.

Instance

Endpoints that resolve an instance's chrome — the core.instance.shell.v1 record that frames every screen of Studio. See Instance chrome for the concept.

MethodPathDescription
GET/v1/instance/shellThe resolved instance chrome record
GET/v1/instance/bootstrapChrome + instance metadata + viewer identity, in one round-trip

GET /v1/instance/shell

Returns the resolved core.instance.shell.v1 record. Requires a valid bearer token (records:read scope).

Query parameterDescription
lifecyclepublished (default) — the live chrome. draft — the owner's unpublished draft; owner-only.
{
  "object": "instance_shell",
  "version": 4,
  "shell": { "kind": "core.instance.shell.v1", "...": "..." }
}

Returns 404 when no chrome has been published — a renderer falls back to its bundled default.

GET /v1/instance/bootstrap

The aggregate a renderer calls on load. Returns the published chrome, the instance metadata, and the caller's identity together, so the frame draws without a waterfall of requests.

This endpoint is auth-invariant — it needs no token. An anonymous caller gets the public surface; a caller presenting a valid token additionally sees their resolved scopes in the identity block.

{
  "object": "instance_bootstrap",
  "shell": { "...": "core.instance.shell.v1 record, or null" },
  "metadata": { "...": "core.instance.metadata.v1 record, or null" },
  "identity": { "object": "identity", "is_authenticated": false, "scopes": [] },
  "is_owner": false,
  "ts": "2026-05-20T18:30:00Z"
}

shell is null when no chrome has been published. is_owner is true only when the caller's identity matches the chrome's owner.

Federation sync

Federation is pull-first HTTP replication between two instances. Both flat (/v1/sync/*) and domain-grouped (/v1/federation/sync/*) routes are served.

Changes feed

MethodPathDescription
GET/v1/sync/changes?thread=X&since=CURSOR&limit=N&feed=MODE&target_namespace=NSCursor-paginated changes feed

Feed modes:

  • normal (default) — single response, returns up to limit (default 1000, max 10000) records
  • longpoll — holds the connection up to 30s waiting for new records
  • continuous — Server-Sent Events stream (for live subscriptions)

Response: {records: [{id, record}], next_cursor: "opaque", has_more: bool}. Records are consent-filtered per target_namespace (see consent guide).

Record batch fetch

MethodPathDescription
POST/v1/sync/recordsFetch specific records by ID (federation-flagged path requires sig)

Request body: {ids: [...]} or {records: [...]}. When the x-syncropel-federation: 1 header is set, each record must include a sig field — Ed25519 over canonical JSON. Unsigned federation requests return HTTP 422. Replay of previously-accepted records is deduped via content-addressed hash.

Pair management

MethodPathDescription
GET/v1/sync/pairsList all sync pairs on this instance
POST/v1/sync/pairsCreate a pair (target pulls from source)
GET/v1/sync/pairs/{id}Pair detail (state, cursor, retries, last_error)
DELETE/v1/sync/pairs/{id}Remove a pair
POST/v1/sync/pairs/{id}/pausePause pulling
POST/v1/sync/pairs/{id}/resumeResume pulling
POST/v1/sync/pairs/{id}/kickForce immediate poll regardless of backoff

Create body: {peer_did, peer_url, thread_id}. Response includes pair_id and, if the instance detects a loopback misconfiguration, a warning field with the recovery command.

The pair direction is semantically "target pulls source" — creating a pair on B with peer_did=A means records flow A→B. For bidirectional sync, create a pair on each side.

Pair state is persistent across instance restart — the registry is rebuilt from lifecycle records on a reserved control thread at startup.

Federation health + per-pair stats

MethodPathDescription
GET/v1/sync/healthAggregate mesh health — pair counts by state, records pulled per hour/minute, mean/p50/p95 delivery latency, top slowest + error-prone pairs, active drift alerts
GET/v1/sync/pairs/{id}/statsPer-pair detail — rolling rate, latency histogram, error count last hour, poll interval state, cumulative counters

Aggregate response shape (elided):

{
  "object": "sync_health",
  "pairs_total": 5,
  "pairs_by_state": { "running": 3, "failing": 1, "paused": 1 },
  "records_pulled_last_hour": 2147,
  "mean_delivery_latency_ms": 847,
  "p95_delivery_latency_ms": 2410,
  "slowest_pairs": [...],
  "most_errored_pairs": [...],
  "drift_alerts": [...]
}

Metrics are in-memory with a 1-hour rolling window — ephemeral, reset on instance restart. The instance-wide /health endpoint also includes a federation summary for one-glance status.

LAN peer discovery

MethodPathDescription
GET/v1/discovery/mdnsList peers the local instance has discovered via LAN mDNS browsing

Returns a roster of peers with DID, endpoint, namespace, handle, capabilities (all from their TXT records), and last_seen timestamps. Used by spl fleet sync peers discover --method mdns.

The instance advertises itself on _syncropel._tcp.local when [sync.discovery] mdns_broadcast = true; it listens for other instances' advertisements by default. Failures during mDNS init are logged but never fail the instance (LAN discovery is a soft feature).

did:sync directory

MethodPathDescription
POST/directory/genesisCreate a new did:sync identity (genesis operation)
POST/directory/update/{hash}Rotate keys or update service endpoints
POST/directory/revoke/{hash}Revoke a did:sync identity
GET/directory/{hash}/did.jsonResolve a did:sync to its DID document
GET/directory/{hash}/operationsFull operation log for a did:sync
GET/directory/handle/{handle}Resolve a human-readable handle to its did:sync

Gated by config.directory_enabled — off by default. An instance can act as a did:sync directory provider; most installs don't need to.

Service accounts

MethodPathScopeDescription
POST/v1/bootstrap/service-account— (unauthenticated)Create the first SA on a fresh install. One-shot per namespace
GET/v1/service-accountsadminList service accounts
POST/v1/service-accountsadminCreate a new service account + token
DELETE/v1/service-accounts/{id}adminRevoke a service account (invalidates all its tokens)
POST/v1/service-accounts/{id}/rotate-keyadminMint a new token for an SA and revoke previous tokens

Bootstrap the first service account

POST /v1/bootstrap/service-account is a one-shot, unauthenticated endpoint used to create the first SA on a fresh instance. Once any SA exists in the target namespace, the endpoint returns 409 BOOTSTRAP_CLOSED and you must use the authenticated POST /v1/service-accounts path instead.

curl -X POST http://localhost:9100/v1/bootstrap/service-account \
  -H "Content-Type: application/json" \
  -d '{
    "service_account_id": "sa_abc123def456ghi7",
    "display_name": "First admin",
    "scopes": ["admin"],
    "namespace": "default",
    "with_token": {
      "token_id": "a3f9e7d1c8b2h6j4k5m7n9p1q3r5t7v9",
      "env_tag": "prod",
      "created_at": "2026-04-20T12:30:00Z"
    }
  }'

Response (201 Created):

{
  "object": "bootstrap_result",
  "service_account_id": "sa_abc123def456ghi7",
  "sa_record_id": "7a2936eccf6b...",
  "namespace": "default",
  "token_id": "a3f9e7d1c8b2...",
  "token_record_id": "151439fe2972..."
}

The client is responsible for generating service_account_id + token_id. The CLI (spl service-account create --bootstrap --with-token) handles this automatically.

List service accounts

curl -H "Authorization: Bearer $SPL_TOKEN" \
  http://localhost:9100/v1/service-accounts

Returns an array. api_key is always null in list responses — plaintext tokens are only disclosed at creation time.

[
  {
    "id": "sa_abc123def456ghi7",
    "name": "CI runner",
    "did": "did:sync:system:sa_abc123def456ghi7",
    "api_key": null,
    "active": true,
    "created_at": null
  }
]

Create a service account + token

curl -X POST http://localhost:9100/v1/service-accounts \
  -H "Authorization: Bearer $SPL_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "Webhook integration"}'

Response (201 Created) includes the plaintext bearer token once — save it immediately:

{
  "id": "sa_new123...",
  "name": "Webhook integration",
  "did": "did:sync:system:sa_new123...",
  "api_key": "spl_prod_sa_new123_a3f9e7d1c8b2h6j4k5m7n9p1q3r5t7v9",
  "active": true,
  "created_at": null
}

Revoke a service account

DELETE /v1/service-accounts/{id} performs per-SA revocation: every token ever minted for the SA becomes invalid, and future token mints for the SA are blocked.

curl -X DELETE -H "Authorization: Bearer $SPL_TOKEN" \
  http://localhost:9100/v1/service-accounts/sa_abc123def456ghi7

Response (200): {"status": "revoked"}.

Rotate an SA's key

POST /v1/service-accounts/{id}/rotate-key mints a new token first, then revokes previous tokens. Guarantees callers don't get locked out on partial failure.

curl -X POST -H "Authorization: Bearer $SPL_TOKEN" \
  http://localhost:9100/v1/service-accounts/sa_abc123def456ghi7/rotate-key

Response (200):

{
  "id": "sa_abc123def456ghi7",
  "api_key": "spl_prod_sa_abc123_new_secret_here"
}

Error codes

CodeNameMeaning
401AUTH_REQUIREDNo bearer token sent, or token is invalid / revoked
403SCOPE_FORBIDDENToken is valid but lacks the required scope for this endpoint
403DID_CLAIM_DENIEDX-Syncropel-Actor is not in the SA's allowed-actors list
409BOOTSTRAP_CLOSEDBootstrap endpoint called but namespace already has an SA

Pair-share-invite

The surface for adding either a new device of yours (guest holder) or a federated colleague (federated holder). Operator-facing guide: Pair, share, invite. SDK surface: client.invites.* in the TypeScript SDK guide.

MethodPathScopeDescription
GET/v1/scope_presets— (public)Discover preset scope shapes (reader / contributor / admin)
POST/v1/invitesadmin (always — even on auth.required=false)Issue a new pair invite
GET/v1/invitesadminList every invite this instance has issued + per-row folded state
GET/v1/invites/{id}— (public)Public fold-state preview consumed by the QR landing page
POST/v1/invites/{id}/redeem— (public; holder credential in body)Guest or federated holder mints SA + bearer
POST/v1/invites/{id}/revokeadminIdempotent revoke
GET/v1/invites/auditadmin (always)Feed of core.invite.event.v1 records from th_audit_invites
POST/v1/invites/bulk-revokeadmin (always)Sweep every expired or exhausted invite in one call
POST/v1/invites/sign-attestationbearer requiredHolder home-instance signs an Ed25519 attestation for federated redeem
GET/v1/invite-templatesadmin (always)List saved invite templates (core.invite_template.v1)
POST/v1/invite-templatesadmin (always)Save a template
POST/v1/tokens/{token_id}/rotatebearer of the token being rotated (self-only)Slide bearer expiry forward

The (always) annotation on admin routes is load-bearing: even with the instance's auth.required = false master switch enabled (auth-off mode for local dev), the operator routes refuse anonymous callers via a separate always_admin_auth_route gate. The auth-off switch covers data-plane endpoints; the admin gate is independent.

Issue a pair invite

curl -X POST http://localhost:9100/v1/invites \
  -H "Authorization: Bearer $SPL_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "ttl_seconds": 86400,
    "max_uses": 1,
    "scopes": ["records:read", "records:write"],
    "accept_holder_types": ["guest"],
    "device_label": "Alice phone",
    "notes": "personal device — for travel"
  }'

Response (201):

{
  "object": "invite",
  "invite_id": "inv_a3f9...",
  "record_id": "7a29...",
  "issuer_did": "did:sync:user:alice",
  "issuer_key_fp": "abc123...",
  "issued_at": "2026-05-23T18:30:00Z",
  "expires_at": "2026-05-24T18:30:00Z",
  "max_uses": 1,
  "uses_remaining": 1,
  "redirect_url": "https://alice.syncropel.app/i/inv_a3f9...?sig=...",
  "qr_payload": "https://alice.syncropel.app/i/inv_a3f9...?sig=..."
}

The optional scope_target body field narrows the invite — {"kind": "thread", "thread_id": "th_..."} or {"kind": "namespace", "namespace": "..."} instead of the default {"kind": "instance"}.

Membership envelope. An optional membership object turns the invite principal-creating — redemption mints an identity + membership grant instead of a bare credential:

{
  "ttl_seconds": 86400,
  "membership": {
    "preset": "contributor",
    "ttl_seconds": 15552000,
    "label": "mora"
  }
}

preset resolves the membership grant's scopes (reader / contributor / admin); membership.ttl_seconds is the grant lifetime (1 hour to 10 years, default 180 days — distinct from the invite link's own ttl_seconds); the optional label pins the member name, which is how renewal works — redeeming a label-pinned invite for an existing custodial member renews the same principal with a fresh grant + bearer, revoking the old grant. CLI: spl invite create --member --preset contributor.

Preview an invite (public)

curl https://alice.syncropel.app/v1/invites/inv_a3f9...

Returns folded state: revoked, expired, uses_remaining, accept_holder_types, scopes, and the issuer's signature for verification. No bearer required — this is what the QR landing page reads.

Redeem — guest holder

A guest redemption mints a principal, not just a credential: a custodial anchor, the guest DID kept as a label, and a guest-classed grant that the bearer is parented to. Revoking that grant fails the bearer on its next request (401 GRANT_REVOKED), and the guest can keep a key to come back on another device. The response gains guest_principal: {guest_did, master_did, grant_id}.

curl -X POST https://alice.syncropel.app/v1/invites/inv_a3f9.../redeem \
  -H "Content-Type: application/json" \
  -d '{
    "holder_type": "guest",
    "holder_pubkey": "base64url-encoded-ed25519-pubkey",
    "holder_label": "Alice phone"
  }'

Response includes the new bearer (bearer), the actor DID Syncropel minted for the holder (actor_did), the granted scopes, and the bearer's expires_at.

Redeem — federated holder

curl -X POST https://alice.syncropel.app/v1/invites/inv_a3f9.../redeem \
  -H "Content-Type: application/json" \
  -d '{
    "holder_type": "federated",
    "holder_did": "did:sync:user:bob",
    "holder_proof": "base64url-encoded-ed25519-signature",
    "holder_proof_issued_at_ms": 1729900000000,
    "signer_instance_did": "did:sync:instance:bob.syncropel.app"
  }'

Syncropel resolves Bob's DID document, verifies the Ed25519 signature against the signer_instance_did's key, and rejects (with audit) on bad_proof, key_unknown, or freshness_violation (issued-at-ms outside the ±60s window).

Redeem — membership invite

When the invite carries a membership envelope, the redeem body names the member and accepts custody:

curl -X POST https://alice.syncropel.app/v1/invites/inv_a3f9.../redeem \
  -H "Content-Type: application/json" \
  -d '{
    "member_label": "mora",
    "custody": "custodial"
  }'

member_label follows the label grammar (lowercase [a-z0-9][a-z0-9-]*, max 63 chars) and becomes did:sync:user:<label>. custody must be "custodial" today — "device" returns 501 Not Implemented. Redemption writes the identity spine (genesis + binding + core.identity.grant.v1 + custodian-signed core.identity.acceptance.v1), then mints a service account + bearer parented to the grant; the response carries the bearer, the member DID, scopes, and both expiries (bearer + grant). An unpinned invite redeemed against an existing label returns 409 MEMBER_LABEL_TAKEN.

Bulk revoke

curl -X POST http://localhost:9100/v1/invites/bulk-revoke \
  -H "Authorization: Bearer $SPL_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"filter": "expired", "reason": "scheduled cleanup"}'

Response: {"revoked_count": N, "invite_ids": [...], "filter": "expired"}. Allowed filter values are expired and exhausted.

Rotate a bearer

curl -X POST http://localhost:9100/v1/tokens/$TOKEN_ID/rotate \
  -H "Authorization: Bearer $CURRENT_BEARER"

Self-only — Syncropel computes sha256($CURRENT_BEARER) and rejects with ROTATE_NOT_SELF if it doesn't match $TOKEN_ID. Response includes the new bearer + new expires_at. The previous bearer is invalidated.

Error codes

CodeNameMeaning
401AUTH_REQUIREDAdmin route called without a bearer (master auth.required switch is bypassed for admin routes)
403SCOPE_FORBIDDENBearer present but lacks admin scope
403CONSENT_DENIEDBearer scope OK but the consent grant for this thread/namespace is absent (defense-in-depth)
404INVITE_NOT_FOUNDInvite id doesn't exist or was never folded
410INVITE_EXPIREDexpires_at passed
410INVITE_REVOKEDInvite was revoked
410INVITE_EXHAUSTEDuses_remaining reached 0
403HOLDER_TYPE_MISMATCHaccept_holder_types doesn't include the redeem's holder_type
403BAD_PROOFFederated holder's Ed25519 signature failed verification
403KEY_UNKNOWNsigner_instance_did not resolvable
403FRESHNESS_VIOLATIONholder_proof_issued_at_ms outside the ±60s window
403ROTATE_NOT_SELFRotation attempted by a bearer different from the path's token_id
409MEMBER_LABEL_TAKENMembership redeem against a label that already belongs to a member, from an invite not pinned to that label (the takeover guard)
501—Membership redeem with custody: "device" — custodial custody is the only implemented mode
401RECREDENTIAL_REFUSEDThe one constant-shape refusal of POST /v1/recredential: unknown principal, no binding, or wrong proof, indistinguishably
409NO_LIVE_GRANTA proof verified, but nothing is granted to that principal any more; no bearer that reads nothing is minted
409ROTATION_REQUIRES_PROOFA binding of that kind already exists; rotating it needs the current proof
429RATE_LIMITEDRe-credential attempts are rate-limited for that principal string or address
409PASSKEY_NOT_ACCEPTEDThe instance declares no relying party, so it takes no passkey
404CREDENTIAL_NOT_FOUNDDELETE /v1/self/credentials/{bearer_id}: not yours and does not exist answer alike

Members

The membership roster + offboarding surface. Concept: Principals & grants. Operator walkthrough: Add a teammate.

MethodPathScopeDescription
GET/v1/membersrecords:readRoster of invite-minted principals. Self-scoping: admin sees all rows; any other caller sees only their own
POST/v1/members/adoptadminAdopt an existing actor as a principal; with sa_id, adopt an existing service account's holder and re-parent the credential to a membership grant (bearer-preserving)
DELETE/v1/members/{label}records:writeRevoke a membership: the grant is revoked and every credential parented to it (bearers, agent sub-grants) dies with it. Bilateral — the caller must be an admin or the member themselves. Idempotent

List members

curl -s http://localhost:9100/v1/members \
  -H "Authorization: Bearer $SPL_TOKEN"

Rows carry member (the DID), status, scopes, and expires_at. A non-admin caller receives exactly their own row — a one-row answer is complete, not truncated.

Adopt

curl -X POST http://localhost:9100/v1/members/adopt \
  -H "Authorization: Bearer $SPL_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"label": "mora", "sa_id": "sa_1f8e2c9a"}'

Without sa_id: binds an existing local actor (whose signing seed this instance already holds) as a principal — after which that actor's signed task verdicts verify at ingest. With sa_id: mints a custodial principal for the service account's holder and re-parents the credential without breaking the bearer. Optional ttl_seconds sets the grant lifetime (default 180 days). Idempotent on an existing principal (already_principal: true).

Revoke a membership

curl -X DELETE http://localhost:9100/v1/members/mora \
  -H "Authorization: Bearer $SPL_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"note": "contract ended"}'

The JSON body is optional; note is signed into the revocation record. The response reports grant_id, credentials_revoked, sub_grants_invalidated, and scope — honestly "local": cross-instance credentials are outside this ledger's reach. The revoked member's bearer answers 401 GRANT_REVOKED on its next request; the event lands as member_revoked on the invite audit feed (GET /v1/invites/audit).

Ledgers

A ledger is a read face you declare beside your data: rows are acts of the kinds you name on a thread, joined to their responses through record parentage; columns are expressions over each row; verbs are the acts a viewer may take on a row. The declaration is a configuration record; the rows are always folded live from the thread.

  • GET /v1/threads/{thread}/ledgers lists the ledgers declared on this instance with the row count this viewer is admitted to see, and says capped when a ceiling bit.
  • GET /v1/threads/{thread}/ledgers/{id} serves a page of evaluated rows (cells, groups, and the may: list of verbs this caller's write authority admits), the declared stats computed only over what this viewer was served, and a next_before cursor; ?before=<clock> continues older, ?limit= bounds the page.
  • GET /v1/threads/{thread}/ledgers/{id}/encoding returns the compact text form an assistant is handed, with the same per-row may: list, so it can never propose an act its holder cannot take.

Rows pass the viewer's disclosure resolution before anything is computed over them, so a count or a stat never reveals the existence of a record the viewer cannot read. A declaration whose expressions do not compile, or that names an undeclared record kind, is refused when configuration loads and says so in the log; it never loads as something quieter.

CORS

All /v1/* + /.well-known/* + /directory/* endpoints include CORS headers (tower-http::CorsLayer with permissive defaults). The instance at localhost:9100 is reachable from web UI origins without proxy configuration.

On this page