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:9100The 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 /healthPOST /v1/bootstrap/service-account(one-shot per namespace)GET /.well-known/syncropelPOST /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
| Method | Path | Description |
|---|---|---|
| GET | /health | Health 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
| Method | Path | Description |
|---|---|---|
| POST | /v1/records | Ingest a new record |
| GET | /v1/records/:id | Get a record by ID |
| GET | /v1/records?thread=X | List records filtered by thread |
| GET | /v1/records?actor=X | List records filtered by actor |
| POST | /v1/records/query | Rich query — MongoDB-style filter document over the log |
| POST | /v1/records/search | Semantic search — rank records by cosine similarity to an embedded query |
| POST | /v1/records/embed | Backfill 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.
| Method | Path | Description |
|---|---|---|
| GET | /v1/find?q=X | Blended search. Params: limit (≤100), types (material|thread), explain, prefix (type-ahead — last word matches as a prefix) |
| GET | /v1/search/stats | Index health: coverage, pending items, last indexing failure |
| POST | /v1/search/rebuild | Drop + 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:
| Field | Type | Default | Description |
|---|---|---|---|
thread | string | — | Optional thread scope. Shorthand for adding {"thread": "..."} to the filter; lets the backend use the thread index |
filter | object | {} | MongoDB-style filter document |
sort | object | — | Single-key sort spec: {"clock": -1} or {"created_at": 1} |
limit | int | 100 | Result cap. Clamped to 1000: asking for more returns 1000 and capped: true |
offset | int | 0 | Pagination offset |
explain | bool | false | When 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:
| Code | Name | Meaning |
|---|---|---|
400 | INVALID_QUERY | Unknown operator, unknown field path, type mismatch, malformed filter |
500 | INTERNAL | Storage 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.
Semantic search
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:
| Field | Type | Default | Description |
|---|---|---|---|
query | string | — | Free-text query. Must be non-empty |
k | int | 10 | Top-K to return. Clamped to [1, 100] |
thread | string | — | Restrict to records on this thread |
actor | string | — | Restrict to records emitted by this actor DID |
kind | string | — | Restrict to records whose body.kind matches |
after_clock | int | — | 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:
| Code | Name | Meaning |
|---|---|---|
400 | EMPTY_QUERY | query was absent or blank |
502 | EMBEDDER_FAILED | The configured provider rejected the embedding request |
503 | SEMANTIC_SEARCH_DISABLED | No 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
| Method | Path | Description |
|---|---|---|
| GET | /v1/threads | List all threads |
| GET | /v1/threads/:id/records | Get all records on a thread |
| GET | /v1/threads/:id/state | Get folded thread state |
| GET | /v1/threads/:id/project | Get thread projection (formatted view) |
| GET | /v1/threads/:id/participants | Get thread participants |
Thread Projections
curl "http://localhost:9100/v1/threads/th_abc123.../project?format=tui"Projection formats:
| Format | Description |
|---|---|
tui | Turn-based view for terminal display |
messages | Chat-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.*.
| Method | Path | Description |
|---|---|---|
| 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/mkdir | Create a directory ({ path }) |
| POST | /v1/data/mv | Move/rename ({ from, to }) |
| POST | /v1/data/rm | Remove ({ path, recursive? }) |
| POST | /v1/data/write/init | Begin a write ({ path, size_bytes, content_type?, precondition? }) → { upload_id, chunk_size } |
| PUT | /v1/blobs/upload/:upload_id | Upload a chunk (body = bytes, header Content-Range: bytes <start>-<end>/<total>) |
| POST | /v1/data/write/complete | Commit the write ({ upload_id }) → { content_hash, size_bytes } |
| POST | /v1/data/read-url | Resolve a path to a blob URL ({ path }) → { blob_url, expires_in, via } |
| GET | /v1/blobs/:hash | Fetch content bytes by hash |
| POST | /v1/data/publish | Promote a file to a durable artifact ({ path, storage_class? }) |
| GET | /v1/data/usage | Namespace storage usage + quota |
| GET | /v1/data/capabilities | Data-plane limits (chunk size, version) |
| GET | /v1/data/provenance | Clock-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.
| Method | Path | Description |
|---|---|---|
| GET | /v1/folds/:name | Resolve a global fold (e.g. trust, task_list) |
| GET | /v1/folds/:name/:key | Resolve a per-thread fold (key = thread id; e.g. turn, thread_state) |
| GET | /v1/folds/frame/:thread | The renderable frame for a thread |
| GET | /v1/folds/rollup | A digest across everything you reach (pass ?root=⊤ for your whole reach) |
| GET | /v1/folds/principal_trust | Trust 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).
| Method | Path | Description |
|---|---|---|
| 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=centrality | Most-connected nodes in reach |
| GET | /v1/graph/facets | The 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
| Method | Path | Description |
|---|---|---|
| GET | /v1/trust | Get all trust scores |
Actors
| Method | Path | Description |
|---|---|---|
| GET | /v1/actors | List registered actors |
| GET | /v1/actors/lookup?did=X | Get actor detail by DID |
| GET | /v1/actors/roster | Every 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}/usage | The 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}/provenance | The 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/materialize | Install 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
| Method | Path | Description |
|---|---|---|
| POST | /v1/dispatch | Dispatch 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
| Method | Path | Description |
|---|---|---|
| POST | /v1/work/loop | Start a work loop. Body: {goal, max_turns?, token_budget?, wall_clock_secs?, workspace?}. Returns the loop's thread id. |
| POST | /v1/work/loop/preview | Cost 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}/cancel | Cancel 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}/stream | SSE live progress for the given loop thread. Alias for the thread-watch handler — the loop thread itself is the filter. |
| GET | /v1/work/compensation/review | Per-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/top | Top 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
| Method | Path | Description |
|---|---|---|
| GET | /v1/decisions | List pending decisions |
| POST | /v1/decisions/:id/decide | Answer 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
| Method | Path | Description |
|---|---|---|
| GET | /v1/engine/health | Engine health counters |
| GET | /v1/engine/expression_cache/stats | CEL expression cache statistics (hit rate, size, avg compile time) — used by spl doctor |
| GET | /v1/config/rules | List routing rules |
| GET | /v1/adapters | List registered adapters |
Expression cache stats
curl http://localhost:9100/v1/engine/expression_cache/statsReturns 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:
GET /v1/threads→ enumerate threadsGET /v1/threads/:id/records→ fetch records per thread- Filter client-side by category (system actor, the decision gate verdict, dispatch outcome, governance) and time window
- 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
| Method | Path | Description |
|---|---|---|
| POST | /v1/proxy/messages | Messages-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.
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /v1/capabilities | authenticated (optional when auth is off) | Client-facing capability manifest |
| GET | /.well-known/syncropel | unauthenticated | Public 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 | jqResponse (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:
| Key | Audience | Purpose |
|---|---|---|
capabilities_manifest | Prospective client | Same shape as /v1/capabilities — a peer can feature-detect before authenticating |
federation_manifest | Prospective federation peer | Ed25519-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:
| Field | Purpose |
|---|---|
instance.did | The DID this manifest is authoritative for. Must match the DID served at the domain's DID document |
federation.pair_endpoint | Entry point for the pair handshake |
advertises.kinds | body.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.refs | Content-addressed REFERENCE identifiers this instance holds — used by future DHT discovery as the primary announce key |
advertises.namespaces | Namespace 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_ref | Pointers back into the same envelope; clients follow them to fetch the sibling manifests |
signature.expires_at | Forces 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
| Method | Path | Description |
|---|---|---|
| GET | /v1/identity | The 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:
| Field | Description |
|---|---|
is_authenticated | true for any request that reached this endpoint with a valid token. |
scopes | The token's resolved capability scopes (admin is the catch-all). |
service_account_id | The service account behind the token, or null for a key-based identity. |
expires_at | When 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.
| Method | Path | Description |
|---|---|---|
| GET | /v1/self | Who this credential is: label, standing, bindings ({recovery_code, passkey, account} as booleans) and proof_kinds_accepted |
| POST | /v1/self/bindings | Bind 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/bindings | The 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/credentials | Every 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.
| Method | Path | Description |
|---|---|---|
| GET | /v1/instance/shell | The resolved instance chrome record |
| GET | /v1/instance/bootstrap | Chrome + 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 parameter | Description |
|---|---|
lifecycle | published (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
| Method | Path | Description |
|---|---|---|
| GET | /v1/sync/changes?thread=X&since=CURSOR&limit=N&feed=MODE&target_namespace=NS | Cursor-paginated changes feed |
Feed modes:
normal(default) — single response, returns up tolimit(default 1000, max 10000) recordslongpoll— holds the connection up to 30s waiting for new recordscontinuous— 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
| Method | Path | Description |
|---|---|---|
| POST | /v1/sync/records | Fetch 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
| Method | Path | Description |
|---|---|---|
| GET | /v1/sync/pairs | List all sync pairs on this instance |
| POST | /v1/sync/pairs | Create 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}/pause | Pause pulling |
| POST | /v1/sync/pairs/{id}/resume | Resume pulling |
| POST | /v1/sync/pairs/{id}/kick | Force 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
| Method | Path | Description |
|---|---|---|
| GET | /v1/sync/health | Aggregate 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}/stats | Per-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
| Method | Path | Description |
|---|---|---|
| GET | /v1/discovery/mdns | List 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
| Method | Path | Description |
|---|---|---|
| POST | /directory/genesis | Create 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.json | Resolve a did:sync to its DID document |
| GET | /directory/{hash}/operations | Full 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
| Method | Path | Scope | Description |
|---|---|---|---|
| POST | /v1/bootstrap/service-account | — (unauthenticated) | Create the first SA on a fresh install. One-shot per namespace |
| GET | /v1/service-accounts | admin | List service accounts |
| POST | /v1/service-accounts | admin | Create a new service account + token |
| DELETE | /v1/service-accounts/{id} | admin | Revoke a service account (invalidates all its tokens) |
| POST | /v1/service-accounts/{id}/rotate-key | admin | Mint 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-accountsReturns 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_abc123def456ghi7Response (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-keyResponse (200):
{
"id": "sa_abc123def456ghi7",
"api_key": "spl_prod_sa_abc123_new_secret_here"
}Error codes
| Code | Name | Meaning |
|---|---|---|
401 | AUTH_REQUIRED | No bearer token sent, or token is invalid / revoked |
403 | SCOPE_FORBIDDEN | Token is valid but lacks the required scope for this endpoint |
403 | DID_CLAIM_DENIED | X-Syncropel-Actor is not in the SA's allowed-actors list |
409 | BOOTSTRAP_CLOSED | Bootstrap 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.
| Method | Path | Scope | Description |
|---|---|---|---|
| GET | /v1/scope_presets | — (public) | Discover preset scope shapes (reader / contributor / admin) |
| POST | /v1/invites | admin (always — even on auth.required=false) | Issue a new pair invite |
| GET | /v1/invites | admin | List 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}/revoke | admin | Idempotent revoke |
| GET | /v1/invites/audit | admin (always) | Feed of core.invite.event.v1 records from th_audit_invites |
| POST | /v1/invites/bulk-revoke | admin (always) | Sweep every expired or exhausted invite in one call |
| POST | /v1/invites/sign-attestation | bearer required | Holder home-instance signs an Ed25519 attestation for federated redeem |
| GET | /v1/invite-templates | admin (always) | List saved invite templates (core.invite_template.v1) |
| POST | /v1/invite-templates | admin (always) | Save a template |
| POST | /v1/tokens/{token_id}/rotate | bearer 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
| Code | Name | Meaning |
|---|---|---|
401 | AUTH_REQUIRED | Admin route called without a bearer (master auth.required switch is bypassed for admin routes) |
403 | SCOPE_FORBIDDEN | Bearer present but lacks admin scope |
403 | CONSENT_DENIED | Bearer scope OK but the consent grant for this thread/namespace is absent (defense-in-depth) |
404 | INVITE_NOT_FOUND | Invite id doesn't exist or was never folded |
410 | INVITE_EXPIRED | expires_at passed |
410 | INVITE_REVOKED | Invite was revoked |
410 | INVITE_EXHAUSTED | uses_remaining reached 0 |
403 | HOLDER_TYPE_MISMATCH | accept_holder_types doesn't include the redeem's holder_type |
403 | BAD_PROOF | Federated holder's Ed25519 signature failed verification |
403 | KEY_UNKNOWN | signer_instance_did not resolvable |
403 | FRESHNESS_VIOLATION | holder_proof_issued_at_ms outside the ±60s window |
403 | ROTATE_NOT_SELF | Rotation attempted by a bearer different from the path's token_id |
409 | MEMBER_LABEL_TAKEN | Membership 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 |
401 | RECREDENTIAL_REFUSED | The one constant-shape refusal of POST /v1/recredential: unknown principal, no binding, or wrong proof, indistinguishably |
409 | NO_LIVE_GRANT | A proof verified, but nothing is granted to that principal any more; no bearer that reads nothing is minted |
409 | ROTATION_REQUIRES_PROOF | A binding of that kind already exists; rotating it needs the current proof |
429 | RATE_LIMITED | Re-credential attempts are rate-limited for that principal string or address |
409 | PASSKEY_NOT_ACCEPTED | The instance declares no relying party, so it takes no passkey |
404 | CREDENTIAL_NOT_FOUND | DELETE /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.
| Method | Path | Scope | Description |
|---|---|---|---|
| GET | /v1/members | records:read | Roster of invite-minted principals. Self-scoping: admin sees all rows; any other caller sees only their own |
| POST | /v1/members/adopt | admin | Adopt 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:write | Revoke 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}/ledgerslists the ledgers declared on this instance with the row count this viewer is admitted to see, and sayscappedwhen a ceiling bit.GET /v1/threads/{thread}/ledgers/{id}serves a page of evaluated rows (cells, groups, and themay:list of verbs this caller's write authority admits), the declared stats computed only over what this viewer was served, and anext_beforecursor;?before=<clock>continues older,?limit=bounds the page.GET /v1/threads/{thread}/ledgers/{id}/encodingreturns the compact text form an assistant is handed, with the same per-rowmay: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.
CLI Reference
Complete reference for the spl command-line interface.
The API surface
Which doors on a workspace are public (a person or an integrator meets them on purpose) and which are internal, with one line per public door, grouped by what you do with them, and the page that explains each one when there is one.