The API
One surface for everything. Four reads (entity, entities, search,
changelog) plus a watch stream, and seven mutations. A new entity type
never adds an endpoint: the REST path pattern is the same routes for every
group, and the GraphQL schema is generated from the
loaded types. This page is the REST surface, the filter grammar, pagination,
the mutations, errors, versioning, and how you authenticate. Everything here
holds identically over REST and GraphQL.
REST resources
Every group serves the same routes; the resource segment is the type's declared plural:
GET /api/v1/{group}/{plural} # list, filter, watch
POST /api/v1/{group}/{plural} # create / upsert
GET /api/v1/{group}/{plural}/{id}
PATCH /api/v1/{group}/{plural}/{id}
PUT /api/v1/{group}/{plural}/{id} # put addressed at id
DELETE /api/v1/{group}/{plural}/{id} # soft delete
GET /api/v1/{group}/{plural}/{id}/incoming # paged reverse edges
The path carries an entity's full identity: {group}/{plural} names the
type, {id} the id within it. Ids are unique per type, not per tenant, so the
same id may exist in two collections as two unrelated entities, and a resource
read is always scoped to its own collection. There is no cross-type read by
bare id anywhere on the surface.
Reverse edges are a derived view of their own, paged separately so a popular entity's fan-in never inflates its document.
The flat entity
Requests and responses carry the flat entity: one JSON object with
properties, edges, and the server-set fields at the top level. The
five-key envelope is the YAML document form;
REST never wraps. title, body, and the temporal properties appear inside
properties and nowhere else, so PutInput and PatchInput accept them only
there. PutInput carries an optional top-level id and an optional
ifVersion; the CLI is what maps metadata.id and metadata.ifVersion onto
them. The id is how a POST to a collection names the entity it creates; on a
PUT the path already names it, so the path is what the write addresses.
A worked sequence over the to-do list. Add a task (the group and type come from the path):
POST /api/v1/tasks.teild.dev/tasks
{"properties": {"title": "Buy milk", "dueAt": "2026-08-11T09:00:00Z"}}
→ {"id": "kq3v9x2m41pf", "type": "task.tasks.teild.dev",
"properties": {"title": "Buy milk", "status": "open",
"dueAt": "2026-08-11T09:00:00Z"},
"version": 1, "createdAt": "2026-08-04T10:00:00Z"}
List what is open, soonest first (the filter is URL-encoded JSON, the grammar is below):
GET /api/v1/tasks.teild.dev/tasks
?filter={"properties":{"status":{"eq":"open"}}}&orderBy=dueAt
→ {"entities": [...], "cursor": "eyJv…", "head": 4207}
Complete one. A state change is just a patch, and the
declaration stamps
completedAt:
PATCH /api/v1/tasks.teild.dev/tasks/kq3v9x2m41pf
{"properties": {"status": "done"}}
Read the person GitHub linked up. Single-entity reads also carry
propertyMeta (per property: who wrote it, and the alternatives other sources
assert, managed properties explains the
mechanism), and, if you asked by an id that was merged away, canonicalId
tells you where it went (merges):
GET /api/v1/people.teild.dev/people/9f2k
→ {"id": "9f2k", "type": "person.people.teild.dev",
"properties": {"name": "Ada Lovelace", "emails": ["ada@example.com"]},
"propertyMeta": {"name": {"manager": "owner", "tier": "owner",
"alternatives": [{"actor": "function.sync.github.bundles.teild.dev",
"value": "ada"}]}}}
The seven mutations
The complete write surface, for every actor, forever. Each one addresses its
target by full identity: the type beside the id (on REST the path's
{group}/{plural} names the type; on GraphQL the type travels in the mutation's
arguments, as type on patch, delete and merge, as srcType/dstType on
link and unlink, and inside input on put), because an id is unique per
type, never per tenant:
| Mutation | What it does |
|---|---|
put | Create or upsert. Merges and never prunes: what the document omits is left alone. Accepts inline edges, so an entity and its edges commit as one unit. |
patch | Edit in place: properties, labels, annotations. A null value deletes a key. State transitions travel only this way. |
delete | Soft delete: tombstones the entity; hard deletion waits for finalizers to release. |
link | Add one edge between two entities. |
unlink | Remove one edge. Both refuse a mapping's subject edge, which only create-time resolution, merge, and split may move. |
merge | Join two entities of one type; the loser's id resolves to the winner forever (merges). |
split | Reverse one merge, restoring the loser from the merge record. |
A put onto a tombstone restores that record: same id, same row, one
changelog row saying so. It is undelete, not id reuse.
put and patch take an optional ifVersion: the write applies only if the
addressed entity's stored version equals it (a non-existent entity is version
0), else the whole write fails a conflict. It is the safe
read-then-conditional-write primitive.
Edges over REST. link and unlink are first-class REST verbs, not
GraphQL-only, so an edge change is a request against the resource whose edge it
is:
POST /api/v1/{group}/{plural}/{id}/edges/{rel}
DELETE /api/v1/{group}/{plural}/{id}/edges/{rel}
The body is an edge reference: {group, type, id}, or a bare {id} where the
edge declaration already pins one target type. A link body may also carry the
edge's own properties. Both return the refreshed source entity. A put could
always add an edge inline; DELETE is how a REST client removes one, which it
could not do before. ssctl has matching link and unlink commands.
This follows one rule, written into the contract: a resource's operational
verbs live at the resource, its own {group}/{plural}/{id} path. That is
also why the trigger verbs live under automation.teild.dev/triggers/…
(triggers are automation.teild.dev entities), not under core. The old
core.teild.dev/triggers/… verb paths keep answering for one release, each
with a Warning header naming the new location; the collection itself has
already moved, so core.teild.dev/triggers is a not_found.
The filter grammar
A filter is one JSON document, the same shape URL-encoded in REST's ?filter=
and passed whole to GraphQL's filter argument:
{"types": ["task.tasks.teild.dev"],
"properties": {"status": {"eq": "open"},
"dueAt": {"lt": "2026-08-11T00:00:00Z"}},
"labels": {"owner/starred": {"eq": true}}}
typesnames entity types by full name (implied by the path on a REST collection).propertiescarries one condition per property. Which operators a property offers is decided by its declared property type:eq,in, andprefixfor the string family;gt,gte,lt,ltefor numbers and datetimes;containsforrepeatedproperties;existsfor presence. State properties filter here like any other.labelsmatches the short, indexed metadata, and takes the same condition objects a property does:{"owner/starred": {"eq": true}}, never a bare value.idsnarrows to a list of ids within the types already selected.edgeis the one-hop edge predicate:{"rel", "to", "toType"}, entities carrying an edgerel(omit it for any relationship) at that target.deletedpicks the tombstones: absent orfalselists only live entities,truelists only soft-deleted ones.implementsselects every type carrying one trait by its full name, across groups. It is the cross-type query's arm, so use it on GraphQL'sentitiesrather than on a REST collection, whose path has already fixed the type. An interface no loaded type implements is avalidationerror naming it.
A list also takes two shaping parameters beside the grammar: withEdges=1
adds each row's edges map, and withAnnotations=1 adds its annotations.
Both are off by default so a list of a fanned-out entity stays small.
Ordering is orderBy with camelCase columns (dueAt, at:desc,createdAt).
Only declared properties filter and order: filterable, indexed, and declared
are the same set, so a query that would be slow is one the grammar cannot
express.
A list parameter a given mode does not honor is a bad_request that names it,
never a silent success. On a collection list the path names the type, so an
explicit filter.types conflicts and is refused (drop it, or list a different
collection). A watch=1 stream ignores the list-query grammar, so
filter/orderBy/first/after/withEdges/withAnnotations alongside it
are refused. A reverse-edge (incoming) read honors only first/after, so
filter/orderBy are refused. A misspelled ordering column is refused naming
the camelCase replacement, and a malformed filter document is refused naming
the field that would not decode.
Pagination
Lists page forward with a keyset cursor carried behind one opaque token. You
pass first for the page size and, on the next request, the cursor a page
returned as after:
GET /api/v1/tasks.teild.dev/tasks?first=50
→ {"entities": [...], "cursor": "eyJv…", "head": 4211}
GET /api/v1/tasks.teild.dev/tasks?first=50&after=eyJv…
→ {"entities": [...], "cursor": "eyJv…", "head": 4230}
The cursor is opaque, so treat it as a token and never parse it. Its
payload is a keyset position (the last row's sort-key values plus the id
tiebreak), not an
offset, so a deep page costs the same as a shallow one, and the walk is stable
under concurrent writes. The stability guarantee is exact: a cursor walk
sees every row that existed for the whole walk exactly once. A row inserted or
deleted mid-walk may or may not appear; a row that lived throughout is never
skipped and never repeated. The token is bound to the orderBy it was minted
for, so replaying it against a different order is rejected rather than silently
mis-seeking. An exhausted list carries no cursor at all over REST, and an
empty one over GraphQL; both mean the same thing, there is no next page.
The continuation rule is one sentence: transparent sequence numbers are
from and before; opaque cursors are after. The changelog's history and
watch use the transparent seq (it is a real, meaningful ordinal, so its
history response returns a cursor seq to pass as the next before); entity
lists and reverse-edge (incoming) lists use the opaque after cursor.
Every list response also carries the changelog head seq captured at the
snapshot it was served from. Page a collection, then resume a
watch from head: every listed row's change is at or before
head, and the watch replays exactly the changes after it, so the handoff has
no gap and no double-see.
Versioning and discovery
The API is served at /api/v1. The v1alpha1 prefix from before the freeze
stays served as an alias for a sunset window, then goes; new work targets
/api/v1. Every response on the alias carries a Warning header (RFC 7234
warn-code 299) naming /api/v1 as the replacement, so a client on the old
prefix is told to move without breaking.
GET /api is discovery. It is unversioned and unauthenticated, and touches no
tenant, so a client can call it before it holds a token. It reports the served
API versions (v1, and v1alpha1 marked deprecated with its replacement), the
server version, the binary's maximum
schema dialect (a
tenant's own stored dialect is internal to its store and never on the wire; a
binary too old for it refuses the open, which surfaces as unavailable), the
changelog horizon (0 today, meaning full history), and a
feature list. The feature list is what replaces probing for 501s: each entry
names a capability and its stability, and the agent surface reports alpha:
{"versions": [{"name": "v1", "status": "served"},
{"name": "v1alpha1", "status": "deprecated", "replacedBy": "v1"}],
"server": {"version": "…"},
"schema": {"maxDialect": 6, "note": "…"},
"changelog": {"horizon": 0},
"features": [{"name": "triggers", "stability": "stable"},
{"name": "agents", "stability": "alpha"}]}
Within v1 the surface is additive only: fields and types are added, never
removed or narrowed under the same version. A deprecation is signalled, not a
silent break: a Warning HTTP header on the REST response and @deprecated
on the GraphQL schema element, each with a minimum sunset window before removal.
There is no Kubernetes-style multi-version conversion machinery; one version is
served, plus the alias during its sunset.
Authentication and actors
Access is by bearer token. The first token is minted with a one-time TOTP code (handed over when the tenant is created), so bootstrap is resource creation like everything else:
POST /api/v1/core.teild.dev/tokens
{"tenant": "ada", "otp": "123456", "name": "cli", "actors": ["owner"]}
→ 201 {"token": {...}, "secret": "teild_tok_ada_…"}
otp is the tenant's current 6-digit TOTP code, never the seed. Every request
after that carries Authorization: Bearer teild_tok_ada_…. The secret is shown
exactly once; the server stores only its hash, so a lost token is revoked and
re-minted, never recovered. With a live token, minting further tokens is the
same request without the otp, naming the new token and the actors it may
write as. Tokens list and revoke as metadata only, never the secret.
Writes are attributed to an actor: a token may carry several (owner, an
integration's own name), and a request picks one with X-Substrate-Actor; the
named actor must be in the token's set, and the default is the token's first
actor. Attribution is load-bearing three ways:
- Provenance: every accepted write records its actor as the property's manager (managed properties), and every changelog row names who wrote.
- Yield: mapping recompute yields to any manager outside the machine tier, which is how a hand edit survives a sync.
- Self-exclusion: a function never sees writes carrying its own actor, which is one half of what keeps functions from looping.
The manager tier an actor's writes hold at is explicit data on the actor,
never derived from its name: a declared actor document may carry
tier: owner|extension|machine (machine is the default for group-declared
actors), function and agent dispatch stamps the extension tier on its own
writes, and an actor no declaration knows, a stranger's own client, holds at
the owner tier. The tier is read from the live declarations on every write, not
frozen when a token was minted, so re-declaring it takes effect at once.
Expiry and scopes
A token carries two optional, least-privilege fields set at mint. Both default so an existing or unspecified token is unaffected: no expiry, and full-tenant access.
expiresAt is an RFC 3339 UTC instant. It is server-enforced at
authentication: a token past its expiresAt fails with an auth error, no
revoke step needed. An absent expiresAt never expires.
scopes narrows a token to less than the whole dataset. It is a list of
grants, each naming a group (or a single type within it, by full identity)
and the verbs it allows, read (GET and list) or write (create, put,
patch, delete, link, unlink). Omitting group covers every group, and omitting
type covers every type in the group; there is no wildcard string, an absent
field is the wildcard. Grants are OR'd: a request is allowed as soon as one
grant covers its group and type and carries its verb, and a scoped token
refused outside its scopes gets a forbidden error. An empty scope list is
full-tenant, every group and type, read and write. Every grant must carry at
least one verb, else the mint is a validation error.
POST /api/v1/core.teild.dev/tokens
{"name": "reader",
"expiresAt": "2027-01-01T00:00:00Z",
"scopes": [{"group": "tasks.teild.dev", "verbs": ["read"]}]}
The gate is not REST-only. GraphQL is scoped the same way: entity,
history, and edge traversal check the read verb before answering, entities,
search, and changelog filter row by row, and all seven mutations check the
write verb against the type they address. The changelog is
gated too: a scoped consumer sees only the rows it may read, in history and in
the watch stream alike, and the stream's cursor still advances past the rows it
filtered out, so a scoped resume stays monotonic and never replays.
A mint may only narrow. Scopes and expiry are set on the authenticated mint
(the request carrying a bearer, above): a token may mint a child that holds a
subset of its own scopes and actors, never a superset. A scoped token cannot
mint a full-tenant one, a grant no single held grant covers is refused, and a
token that expires cannot mint a child that outlives it or one with no expiry
at all. Each refusal is a forbidden error naming what exceeded what. The
unauthenticated bootstrap exchange has no caller to narrow from, so it always
mints a full-tenant, non-expiring token, which you then narrow.
Both ssctl (ssctl login) and the web console
drive the bootstrap exchange: tenant name plus the current 6-digit code, a
token minted and stored on their side.
The canonical envelope
The document envelope (group, type, metadata,
data, status) is the one canonical representation of an entity. The flat JSON
that REST and GraphQL carry is a lossless view of it: properties and edges
land under data, metadata holds the id and the authored key spaces, and the
server-set fields (version, timestamps, provenance) land under status. The
mapping round-trips exactly, so ssctl get -o yaml output applies back with no
edit, and a generic client can read, modify, and write the same object.
Edges are one shape in the canonical envelope, a list of
{rel, to, properties} in both directions. The flat read entity groups the
same edges into a relationship-keyed map for convenience; that map converts to
and from the list without loss (the list is the map flattened, one entry per
target, in a stable order), and the list is what a write carries. So a
read-modify-write of an entity with edges is a fixed point.
Every versioned write body and every filter document is decoded strictly.
An unknown key, a miscased key (a lowercase ifversion is not ifVersion), or
a duplicate key is a bad_request naming it, never a silently dropped
precondition or a broadened filter. Openness stays only inside the map-valued
fields that are meant to be open: properties, labels, annotations, an
object-typed or json-typed property, and the filter's per-property operators.
PATCH semantics are pinned:
- A property value replaces whole. The merge is key-wise across the property map only (depth one): patching
{"properties": {"a": {...}}}replaces the whole value ofa, it does not deep-merge into it.labelsandannotationsmerge the same way, key by key. - A top-level
nulldeletes that property (or label or annotation). - A literal
nullas a value is therefore unwritable: a null always means delete, so a property can never read back holdingnull. - A state value among the properties is a transition, not a plain write:
{"properties": {"status": "done"}}drives the state machine and stamps any declared clock.
Status codes follow the write: a create is 201, an update or replace is
200, consistently across POST-to-collection and PUT-at-id.
Errors
Errors are one shape everywhere, with problems carrying the full list when a
batch or a multi-part validation refuses:
{"error": {"code": "validation", "message": "…", "problems": ["…"]}}
The code set is closed. The client-error codes:
| Code | HTTP | When |
|---|---|---|
bad_request | 400 | A malformed request, an unknown field, or an unsupported list parameter. |
validation | 422 | An undeclared property, a malformed value, a type mismatch. |
conflict | 409 | A version check failed (ifVersion); re-read and retry. |
guard | 403 | A refused state transition, or a protected operation (a subject edge, a type with live entities). |
forbidden | 403 | The actor or token may not do this at all. |
auth | 401 | Missing or invalid token, or a refused login exchange. |
not_found | 404 | No such entity; a former id is not this, it resolves (merges). |
rate_limited | 429 | Slow down; the response carries Retry-After. |
The server-error family is split so a client can tell "try again" from "never
going to work": internal (500, an unexpected fault), unsupported (501, a
capability this deployment does not offer, the thing GET /api feature
detection replaces), and unavailable (503, a temporary outage, always with a
Retry-After). The same problem object appears in a GraphQL error's
extensions and in the changelog watch stream's terminal
error frame, so an error means the same thing wherever it surfaces.
One more code lives on the changelog surface: compacted (410) answers a
from= below the retention horizon,
telling a consumer that has fallen too far behind to re-list rather than
silently miss rows. That is the whole closed set; nothing else appears in
error.code.
Next: GraphQL and search, the same graph at one endpoint.