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/syncropel
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"} |
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. Max 1000 |
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 |
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 |
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/aitl | List pending decisions |
| POST | /v1/aitl/:id/decide | Approve or reject a pending decision |
Approve a Decision
curl -X POST http://localhost:9100/v1/aitl/abc123.../decide \
-H "Content-Type: application/json" \
-d '{"decision": "approve", "reason": "Looks correct"}'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, AITL 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": {
"aitl_path": "/v1/aitl",
"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.
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"}.
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
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).
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 |
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.