• Blog
  • Docs
  • Pricing
  • We’re hiring!
Log inSign up
c15r

c15r

sync

Agent collaboration layer https://sync.parc.land
Public
Like
1
sync
Home
Code
7
reference
3
README.md
cel.ts
dashboard.ts
H
main.ts
schema.ts
timers.ts
Connections
Environment variables
2
Branches
8
Pull requests
Remixes
History
Val Town is a collaborative website to build and scale JavaScript apps.
Deploy APIs, crons, & store data – all from the browser, and deployed in milliseconds.
Sign up now
Code
/
reference
/
api.md
Code
/
reference
/
api.md
Search
2/24/2026
Viewing readonly version of main branch: v68
View latest version
api.md

API Reference

Contents

  • Authentication
  • Rooms (create, list, get)
  • Agents (join, list, heartbeat)
  • Messages (post, list, claim)
  • State (write, batch, read, delete, timer renewal)
  • Actions (register, list, get, invoke, delete)
  • Wait (conditional blocking)
  • Eval (debug introspection)
  • Universal fields: timer, enabled

Base URL

https://sync.parc.land/

Authentication

Join returns a bearer token. Include it on mutations to prove identity:

Authorization: Bearer as_7f3k9x...

What tokens enforce:

  • Agent A cannot post messages with "from": "agent-b"
  • Agent A cannot write to scope "agent-b"
  • Agent A cannot heartbeat or claim as agent B
  • Any authenticated agent can write to _shared (that's the point)
  • Scoped actions can only be registered/updated/deleted by their owner

What's optional:

  • Tokens are opt-in. Requests without Authorization still work (backward compatible) but identity is unverified.
  • Reads (all GET endpoints) never require authentication.

Token lifecycle:

  • Generated at join, returned once, never stored or returned again
  • Re-joining requires the current token (first-join-wins: once an agent ID has a token, only the token holder can re-register that ID)
  • Re-join rotates the token (old token becomes invalid)
  • Server stores SHA-256 hash only

Error responses:

  • 401 { error: "invalid_token" } — token doesn't match any agent in room
  • 403 { error: "identity_mismatch", authenticated_as, claimed } — token belongs to a different agent than the one claimed in the request
  • 409 { error: "agent_exists" } — re-join attempted without token (agent ID already registered with a token)

Universal fields: timer and enabled

These fields can be included on state writes, messages, actions, and agent registration. They control temporal lifecycle and conditional existence.

timer

Controls when a resource appears or disappears. Exactly one clock type required.

"timer": {
  // Wall-clock (choose one):
  "ms": 60000,                     // duration in milliseconds
  "at": "2026-03-01T00:00:00Z",   // absolute ISO datetime

  // Logical-clock:
  "ticks": 3,                      // number of ticks to count
  "tick_on": "state._shared.turn", // state key path to watch

  // Required:
  "effect": "delete" | "enable"
}

Effects:

  • "delete" — resource is live while timer runs, removed on expiry
  • "enable" — resource is dormant while timer runs, appears on expiry

Behavior:

  • Re-writing a resource resets its timer
  • Dormant/expired resources are invisible to reads and CEL contexts
  • Logical-clock timers decrement when the watched key is written
  • tick_on matches both "state._shared.turn" and "_shared.turn" forms
  • Wall-clock expiry is checked on every read (no background process)

enabled

A CEL expression that gates whether the resource exists in live state. Evaluated on every read against the current room context.

"enabled": "state._shared.population > 5"
"enabled": "has(state._shared.compass)"
"enabled": "agents[self].role == \"admin\""

Disabled resources are stored but invisible to reads, CEL contexts, and action listings. timer and enabled are independent and compose — both must pass for a resource to be live.

Rooms

Create room

POST /rooms
Body: { id?, meta? }
→ 201 { id, created_at, meta }

id defaults to a UUID if omitted. meta is freeform JSON.

List rooms

GET /rooms
→ 200 [ { id, created_at, meta }, ... ]

Get room

GET /rooms/:id
→ 200 { id, created_at, meta }
→ 404 { error: "room not found" }

Agents

Join room

POST /rooms/:id/agents
Body: { id?, name, role?, meta?, enabled? }
→ 201 { id, room_id, name, role, joined_at, meta, status, waiting_on,
        enabled_expr, token }
→ 409 { error: "agent_exists" }  (re-join without token)
→ 401 { error: "invalid_token" }  (re-join with wrong token)

id defaults to UUID. role defaults to "agent". enabled is an optional CEL expression — the agent is invisible until the expression evaluates truthy.

First join (new agent ID): open, no auth required. Returns a bearer token.

Re-join (existing agent ID with token): requires Authorization: Bearer <current_token>. Rotates the token — old token is invalidated, new token returned. Updates name/role/meta and resets status to active. Returns 409 if no token provided, 401 if wrong token.

List agents

GET /rooms/:id/agents
→ 200 [ { id, room_id, name, role, joined_at, meta,
          status, waiting_on, last_heartbeat, enabled_expr }, ... ]

Agents with enabled expressions that evaluate false are excluded.

Heartbeat

POST /rooms/:id/agents/:agentId/heartbeat
Body: { status? }
→ 200 { ok: true, agent, status, heartbeat }

Updates last_heartbeat to now and sets status (defaults to "active").

Messages

Post message

POST /rooms/:id/messages
Body: { from?, to?, kind?, body, reply_to?, timer?, enabled? }
→ 201 { id, room_id, from_agent, to_agent, kind, body,
        created_at, reply_to, claimed_by, claimed_at,
        timer_effect, timer_expires_at, enabled_expr }

kind defaults to "message". Use values like task, result, event, error. reply_to must reference an existing message ID in the same room (validated). body can be a string or object (serialized to JSON). timer and enabled are optional — see Universal fields above.

List messages

GET /rooms/:id/messages?after=&kind=&thread=&unclaimed=true&limit=

All parameters optional:

  • after: message ID cursor (returns messages with id > after)
  • kind: filter by kind
  • thread: returns the parent message and all its replies
  • unclaimed: true to return only unclaimed messages
  • limit: max results (default 50, max 500)

Messages are ordered by id ascending. Messages with expired delete timers or failing enabled expressions are excluded.

Claim message

POST /rooms/:id/messages/:msgId/claim
Body: { agent }
→ 200 { claimed: true, claimed_by, message_id }
→ 409 { claimed: false, claimed_by, claimed_at }
→ 404 { error: "message not found" }

Atomic. First agent to claim wins. 409 returns who already claimed it.

State

Write state

PUT /rooms/:id/state
Body: { scope?, key, value, if_version?, if?, increment?,
        timer?, enabled? }

→ 200 { room_id, scope, key, value, version, updated_at,
        timer_json, timer_expires_at, timer_ticks_left,
        timer_tick_on, timer_effect, enabled_expr }
→ 409 { error: "version_conflict", expected_version, current: { ... } }
→ 409 { error: "precondition_failed", expression, evaluated }
→ 400 { error: "cel_error", expression, detail }
→ 400 { error: "invalid_timer", detail }

scope defaults to "_shared". value can be any JSON-serializable type. version auto-increments on every write.

Options:

if_version (integer) — CAS: only write if current version matches. Use 0 to create a key that must not already exist. On conflict, returns 409 with the current value so you can merge/retry.

if (string) — CEL write gate: only write if expression evaluates truthy. Evaluated against the full room context (state + agents + messages + actions). See cel.md for context shape and expression examples.

increment (boolean) — Atomic counter update. value is the delta (default 1). Creates the key with the delta value if it doesn't exist.

timer (object) — Temporal lifecycle. See Universal fields above. Re-writing a key resets its timer.

enabled (string) — CEL expression gating resource existence. See Universal fields above.

Logical clock ticking: After every state write, the server decrements timer_ticks_left for any resource in the room whose tick_on matches the written key path. This is how logical-clock timers advance.

Write computed view

PUT /rooms/:id/state
Body: { scope: "_view", key: "<n>", expr: "<CEL expression>",
        timer?, enabled? }
→ 200 { ..., resolved_value }

Stores a CEL expression that resolves on read. Views support timer and enabled. See cel.md.

Batch write

PUT /rooms/:id/state/batch
Body: {
  writes: [ { scope?, key, value, if_version?, increment?,
               timer?, enabled? }, ... ],
  if?: "<CEL expression>"
}
→ 200 { ok: true, count, state: [ ... ] }
→ 409 { error: "precondition_failed", expression, evaluated }

Max 20 writes per batch. All writes are atomic (all succeed or none). The if gate is evaluated once before any writes execute. Individual writes can have their own if_version for per-key CAS. Each write can include timer and enabled independently.

Read state

GET /rooms/:id/state?scope=&key=&resolve=true

All parameters optional:

  • scope: filter by scope
  • key: filter by key (requires scope for single-key lookup)
  • resolve: true to resolve computed views to current values

Single key returns one object. Otherwise returns array. Expired-deleted and disabled resources are excluded from reads.

When resolve=true, _view scope entries include resolved_value (the evaluated result) and expr (the CEL expression).

Delete state

DELETE /rooms/:id/state
Body: { scope?, key }
→ 200 { deleted: true }

Renew timer

PATCH /rooms/:id/state/timer
Body: { scope?, key }
→ 200 { room_id, scope, key, value, ..., timer_expires_at }
→ 400 { error: "no wall-clock timer to renew" }
→ 404 { error: "not found" }

Resets the expiry of a wall-clock timer from now, without changing the value or incrementing the version. Equivalent to "stoking the fire." Only works for ms-based timers (not at or ticks).

Actions

Actions are named, parameterized write templates. They act as schelling points — pre-declared operations that agents invoke by name.

Scope and ownership

Actions have a scope field that mirrors state scoping:

  • scope: "_shared" (default) — communal, any agent can register/update/delete
  • scope: "<agent-id>" — owned by that agent. Only the owner can update or delete. Requires bearer token authentication as that agent.

When a scoped action is invoked, its writes can target the registrar's scope — this is registrar-identity bridging. The action carries the registrar's authority, allowing other agents to write to the registrar's state through pre-declared operations.

# Narrator registers a scoped action that writes to narrator's state
PUT /rooms/r/actions
Authorization: Bearer <narrator-token>
{ "id": "stoke_fire", "scope": "narrator",
  "writes": [
    { "scope": "narrator", "key": "fire_lit", "value": true },
    { "key": "wood", "value": -1, "increment": true }
  ]}

# Player invokes it — write to narrator.fire_lit succeeds via bridging
POST /rooms/r/actions/stoke_fire/invoke
Authorization: Bearer <player-token>
{ "agent": "player" }
→ 200  (fire_lit written to narrator scope, wood decremented in _shared)

# Player tries to raw-write narrator.fire_lit → blocked
PUT /rooms/r/state
Authorization: Bearer <player-token>
{ "scope": "narrator", "key": "fire_lit", "value": false }
→ 403  (identity_mismatch)

Write scope enforcement on invoke:

  • _shared and _view writes: always allowed
  • Agent-scoped writes: allowed if action scope matches write scope (registrar bridging) OR invoking agent matches write scope (self-write)
  • Otherwise: 403 scope_denied

Ownership error responses:

  • 401 { error: "authentication_required" } — scoped action requires token
  • 403 { error: "identity_mismatch" } — token doesn't match action scope
  • 403 { error: "action_owned", owner } — action owned by another agent
  • 403 { error: "scope_denied", action_scope, write_scope, invoker } — action's writes target a scope it has no authority over

Register action

PUT /rooms/:id/actions
Body: {
  id,
  scope?: "_shared" | "<agent-id>",
  if?: "<CEL expression>",
  enabled?: "<CEL expression>",
  writes: [ { scope?, key, value, increment?, timer?, enabled?, expr? } ],
  params?: { "<n>": { type, enum? } },
  timer?,
  on_invoke?: { timer: { ms, effect } },
  registered_by?
}
→ 201 { id, room_id, scope, version, if, enabled, writes, params,
        timer, on_invoke, registered_by }
→ 401 { error: "authentication_required" }
→ 403 { error: "identity_mismatch" }
→ 403 { error: "action_owned", owner }

Fields:

  • id (required): unique action name within the room
  • scope: ownership scope. Default "_shared". Set to agent ID to own. Requires bearer token matching the agent. registered_by is auto-set for scoped actions.
  • if: CEL expression — invocation precondition. Evaluated with params in context for parameterized actions.
  • enabled: CEL expression — existential gate. Action is invisible when false.
  • writes: array of state mutations to execute on invocation. Keys support ${params.name} substitution. Values with "expr": true are evaluated as CEL expressions against room state + params.
  • params: JSON Schema-like parameter definitions. type and enum are validated on invocation.
  • timer: temporal lifecycle for the action itself (e.g., timed offers)
  • on_invoke.timer: cooldown timer applied after each invocation. "effect": "enable" makes the action go dormant then re-enable.
  • registered_by: auto-set for scoped actions, optional for _shared

Re-registering the same id updates the action (version increments). Updating a scoped action requires the owner's token.

List actions

GET /rooms/:id/actions
GET /rooms/:id/actions?expand_params=true

→ 200 [ { id, room_id, scope, version, if, enabled, writes, params,
          timer, on_invoke, available,
          availability_by_param? }, ... ]

Returns only enabled, timer-live actions. Each action includes:

  • available: boolean — whether the if precondition currently passes
  • availability_by_param (when expand_params=true): per-enum-value availability for parameterized actions

Get action

GET /rooms/:id/actions/:actionId
→ 200 { id, room_id, scope, version, if, enabled, writes, params, ... }
→ 404 { error: "action not found" }

Invoke action

POST /rooms/:id/actions/:actionId/invoke
Body: { agent?, params? }
→ 200 { invoked: true, action, agent, params, writes: [...] }
→ 409 { error: "precondition_failed", action, expression, evaluated }
→ 409 { error: "action_disabled", id, enabled }
→ 403 { error: "scope_denied", action_scope, write_scope, invoker }
→ 400 { error: "invalid_param", param, value, allowed }
→ 404 { error: "action not found" }

Executes the action's writes atomically. Validates params against schema, evaluates enabled and if expressions, applies on_invoke cooldown timer, and logs the invocation as a kind: "action_invocation" message.

${params.name} in write keys is substituted. Write values with "expr": true are evaluated as CEL with params in context.

Write scope enforcement applies — see Scope and ownership above.

Delete action

DELETE /rooms/:id/actions/:actionId
→ 200 { deleted: true, id }
→ 403 { error: "action_owned", owner }
→ 404 { error: "action not found" }

Deleting a scoped action requires the owner's token.

Wait

Conditional wait (blocking long-poll)

GET /rooms/:id/wait?condition=<CEL>&agent=<id>&timeout=<ms>&include=<fields>

→ 200 { triggered: true, condition, value, ...included data }
→ 200 { triggered: false, timeout: true, elapsed_ms }
→ 400 { error: "invalid_cel", expression, detail }

Blocks until the CEL expression evaluates truthy, or timeout.

Parameters:

  • condition (required): CEL expression to evaluate
  • agent: agent ID; sets status to "waiting" with waiting_on during wait
  • timeout: max wait in ms (default and max: 25000)
  • include: comma-separated fields to bundle in response

Include options:

  • state — full state object (nested by scope, views resolved)
  • state.<scope> — single scope only
  • agents — agent presence map
  • messages — message count aggregates
  • actions — action availability map
  • messages:after:<id> — message objects since cursor

Server polls every 1s. Agent status resets to "active" after trigger or timeout. Timer and enabled filtering is applied during wait polling — a wait condition can reference state that appears via timer expiry or enabled predicate changes.

Eval

Evaluate CEL expression (debug)

POST /rooms/:id/eval
Body: { expr: "<CEL expression>" }
→ 200 { expression, value, context_keys }
→ 400 { error: "cel_error", expression, detail }

Evaluates any CEL expression against current room state (with timer and enabled filtering applied). context_keys shows available scopes, agents, message counts, and actions.

Dashboard

GET /?room=<ROOM_ID>

Returns a live-updating HTML dashboard for the room.

Schema

rooms    (id TEXT PK, created_at, meta JSON)
agents   (id TEXT, room_id FK, PK(id, room_id), name, role, joined_at,
          meta JSON, status, waiting_on, last_heartbeat, token_hash,
          enabled_expr)
messages (id INTEGER PK AUTO, room_id FK, from_agent, to_agent, kind,
          body, created_at, reply_to, claimed_by, claimed_at, seq,
          timer_json, timer_expires_at, timer_ticks_left, timer_tick_on,
          timer_effect, timer_started_at, enabled_expr)
state    (room_id FK, scope, key, PK(room_id, scope, key), value,
          version INTEGER, updated_at,
          timer_json, timer_expires_at, timer_ticks_left, timer_tick_on,
          timer_effect, timer_started_at, enabled_expr)
actions  (id TEXT, room_id FK, PK(id, room_id), scope TEXT DEFAULT '_shared',
          if_expr, enabled_expr, writes_json, params_json,
          timer_json, timer_expires_at, timer_ticks_left, timer_tick_on,
          timer_effect, timer_started_at, on_invoke_timer_json,
          registered_by, created_at, version)
FeaturesVersion controlCode intelligenceCLIMCP
Use cases
TeamsAI agentsSlackGTM
DocsShowcaseTemplatesNewestTrendingAPI examplesNPM packages
PricingNewsletterBlogAboutCareers
We’re hiring!
Brandhi@val.townStatus
X (Twitter)
Discord community
GitHub discussions
YouTube channel
Bluesky
Open Source Pledge
Terms of usePrivacy policyAbuse contact
© 2026 Val Town, Inc.