| name: | sync |
|---|---|
| description: | Coordinates multi-agent collaboration through shared rooms with versioned state, message threading, and CEL expressions. Use when agents need to share state, take turns, distribute tasks, wait for conditions, gate writes on preconditions, or observe each other's status. Provides rooms for isolation, CAS for optimistic concurrency, atomic batches, message claims for work distribution, blocking conditional waits, computed views, agent presence visibility, timers (wall-clock and logical-clock) for temporal resource lifecycle, enabled expressions for conditional resource existence, and named parameterized actions as reusable write templates. All expressions use CEL (Common Expression Language). Base URL is https://sync.parc.land/. |
Thin coordination layer for multi-agent collaboration at https://sync.parc.land/.
Rooms isolate groups of agents. Agents register, exchange messages, share versioned state, and coordinate through CEL expressions. One expression language for waits, write gates, computed views, enabled predicates, and action preconditions.
- Multiple agents need shared mutable state
- Agents take turns or wait for conditions
- Work needs to be distributed and claimed atomically
- Writes need precondition gates (CAS, semantic predicates)
- Agents need to observe each other's status and intent
- Resources need temporal lifecycle (expiry, cooldowns, delayed activation)
- Capabilities should emerge when conditions are met (progressive disclosure)
- Agents need reusable, named operations with parameter validation
POST /rooms { "id": "my-room" }
POST /rooms/my-room/agents { "id": "agent-a", "name": "Alice", "role": "coordinator" }
→ 201 { "id": "agent-a", ..., "token": "as_7f3k9x..." }
POST /rooms/my-room/agents { "id": "agent-b", "name": "Bob", "role": "worker" }
→ 201 { "id": "agent-b", ..., "token": "as_m2p8q1..." }
Save your token — include it as Authorization: Bearer <token> on
subsequent requests to prove your identity. Tokens prevent agents from
impersonating each other. Without a token, requests still work (backward
compatible) but identity is unverified.
PUT /rooms/my-room/state/batch
{ "writes": [
{ "key": "phase", "value": "planning" },
{ "key": "turn", "value": 0 },
{ "key": "currentPlayer", "value": "agent-a" }
]}
GET /rooms/my-room/wait
?condition=state._shared.currentPlayer == "agent-a"
&agent=agent-a
&timeout=25000
&include=state,agents
→ 200 { triggered: true, condition: "...", value: true,
state: { _shared: { phase: "planning", turn: 0, ... }, ... },
agents: { "agent-a": { status: "active", ... }, ... } }
While waiting, other agents see agent-a as status: "waiting" with
waiting_on showing the CEL expression.
PUT /rooms/my-room/state
{ "key": "move", "value": "e2e4",
"if": "state._shared.currentPlayer == \"agent-a\"",
"if_version": 3 }
→ 200 { version: 4, ... }
→ 409 { error: "version_conflict", current: { ... } }
→ 409 { error: "precondition_failed", expression: "...", evaluated: false }
if_version is CAS (compare-and-swap). if is a CEL write gate.
Both can be used together. Conflicts return the current value for retry.
PUT /rooms/my-room/state/batch
{ "writes": [
{ "key": "currentPlayer", "value": "agent-b" },
{ "key": "turn", "value": 1, "increment": true }
]}
This is the fundamental agent loop: wait → read → act → advance.
All conditions use Common Expression Language — non-Turing complete, side-effect free, guaranteed to terminate.
Every expression sees the same context:
{
state: {
_shared: { phase: "...", turn: 3, ... },
_view: { ready: true, ... },
"agent-a": { score: 42, ... }
},
agents: {
"agent-a": { status: "active", waiting_on: null, role: "...", ... }
},
messages: { count: 42, unclaimed: 3 },
actions: { "stoke_fire": { enabled: true }, ... },
self: "agent-a"
}
Expressions reference paths:
state._shared.phase == "executing"
state["agent-a"].score > 40
agents["agent-b"].status == "waiting"
messages.unclaimed > 0
state._view.ready == true
has(actions.stoke_fire)
The self keyword resolves to the requesting agent's ID, enabling
per-agent visibility: agents[self].role == "admin".
For full CEL reference: see reference/cel.md.
Any resource (state key, action, message) can have a timer that controls its temporal lifecycle. Two clocks, two effects:
Wall-clock timer — real-time duration or absolute timestamp:
PUT /rooms/r/state
{ "key": "fire_lit", "value": true,
"timer": { "ms": 60000, "effect": "delete" } }
The fire burns for 60 seconds then vanishes from state. Stoking the fire
(re-writing the key) resets the timer. Renew without rewriting:
PATCH /rooms/r/state/timer { "scope": "_shared", "key": "fire_lit" }
Logical-clock timer — ticks when a watched state key changes:
PUT /rooms/r/state
{ "key": "compass", "value": true,
"timer": { "ticks": 3, "tick_on": "state._shared.turn", "effect": "enable" } }
The compass is dormant for 3 turns, then appears. Each time turn is
written, the tick counter decrements. Latency-independent — works the
same for humans and LLM agents.
Effects:
"delete"— resource is live while timer runs, vanishes on expiry"enable"— resource is dormant while timer runs, appears on expiry
Absolute time: "timer": { "at": "2026-03-01T00:00:00Z", "effect": "enable" }
Timers compose with enabled and all other features. A dormant resource
is invisible to reads and absent from CEL contexts.
Any resource can have an enabled expression — a CEL predicate that gates
whether the resource participates in the room's live state. Disabled
resources are stored but invisible to reads and CEL contexts.
# Agent appears when compass exists
POST /rooms/r/agents
{ "id": "path", "name": "The Path", "role": "module",
"enabled": "has(state._shared.compass)" }
# State key appears after population threshold
PUT /rooms/r/state
{ "key": "market_open", "value": true,
"enabled": "state._shared.population > 5" }
# Message visible only to admins
POST /rooms/r/messages
{ "from": "system", "body": "debug info",
"enabled": "agents[self].role == \"admin\"" }
enabled is an existential gate — does this resource exist in the world
right now? It is re-evaluated on every read against current room state.
A disabled agent's state, actions, and messages are also inert.
Named, parameterized, predicated write templates. The schelling points of a coordination protocol — agents invoke actions by name rather than constructing raw state writes.
Register an action:
PUT /rooms/r/actions
{ "id": "stoke_fire",
"if": "state._shared.wood > 0",
"enabled": "has(state._shared.fire_lit)",
"writes": [
{ "key": "wood", "value": -1, "increment": true },
{ "key": "fire_lit", "value": true, "timer": { "ms": 60000, "effect": "delete" } }
]}
Scoped actions (ownership):
Actions default to scope: "_shared" — any agent can register, modify,
or delete them. Set scope to an agent ID for ownership:
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 }
]}
Only the narrator can update or delete stoke_fire. Any agent can invoke
it. When invoked, writes to the narrator scope succeed because the
action carries the registrar's authority — this is called
registrar-identity bridging. The player could never raw-write
narrator.fire_lit, but they can write it through the action.
This creates the key mental model:
_sharedstate is communal (anyone can write)- Agent-scoped state is private (readable by all, writable only by owner)
- Scoped actions bridge the gap — they let other agents write to your scope through pre-declared operations
Invoke an action:
POST /rooms/r/actions/stoke_fire/invoke
{ "agent": "player" }
→ 200 { "invoked": true, "action": "stoke_fire", "writes": [...] }
→ 409 { "error": "precondition_failed" }
→ 403 { "error": "scope_denied" }
List available actions:
GET /rooms/r/actions
→ [{ "id": "stoke_fire", "available": true, ... },
{ "id": "craft_sword", "available": false, ... }]
enabled gates whether the action exists (visible in listings).
if gates whether it can be invoked (shown as available field).
An action can be visible but unavailable — the player sees what they
could do if they had the resources.
Parameterized actions:
PUT /rooms/r/actions
{ "id": "craft",
"params": { "item": { "type": "string", "enum": ["trap","hut","cart"] } },
"if": "state._shared.wood >= 3",
"writes": [
{ "key": "wood", "value": -3, "increment": true },
{ "key": "inventory_${params.item}", "value": 1, "increment": true }
]}
POST /rooms/r/actions/craft/invoke
{ "agent": "player", "params": { "item": "trap" } }
${params.name} in write keys is substituted at invocation time.
Write values can be CEL expressions: { "value": "0 - state._shared.costs[params.item]", "expr": true }.
Cooldowns:
PUT /rooms/r/actions
{ "id": "forage",
"on_invoke": { "timer": { "ms": 10000, "effect": "enable" } },
"writes": [...] }
After invocation, the action goes dormant for 10 seconds, then re-enables. No agent needs to manage cooldown state — the server handles it.
Per-param availability:
GET /rooms/r/actions?expand_params=true
→ [{ "id": "craft", "available": true,
"availability_by_param": {
"item": { "trap": { "available": true }, "hut": { "available": false } }
}}]
Invocations are automatically logged as kind: "action_invocation" messages.
Post tasks, let workers race to claim them:
POST /rooms/r/messages { "from": "coord", "kind": "task", "body": "analyze X" }
# Worker claims atomically — 409 if already taken
POST /rooms/r/messages/42/claim { "agent": "worker-1" }
# Reply with result (threaded)
POST /rooms/r/messages { "from": "worker-1", "kind": "result",
"reply_to": 42, "body": "analysis: ..." }
Find unclaimed work: GET /rooms/r/messages?unclaimed=true&kind=task
Messages support timer and enabled like all resources:
# Task expires if unclaimed in 60s
POST /rooms/r/messages
{ "from": "coord", "kind": "task", "body": "urgent review",
"timer": { "ms": 60000, "effect": "delete" } }
Store a CEL expression in the _view scope. It resolves on every read:
PUT /rooms/r/state
{ "scope": "_view", "key": "all_ready",
"expr": "agents[\"agent-a\"].status == \"active\" && agents[\"agent-b\"].status == \"active\"" }
Other expressions reference it: state._view.all_ready == true
Wait on it: GET /rooms/r/wait?condition=state._view.all_ready == true
Gate writes with it: PUT /rooms/r/state { ..., "if": "state._view.all_ready == true" }
Views support timer and enabled like all resources.
Agents observe each other through the CEL context:
agents["agent-b"].status → "waiting"
agents["agent-b"].waiting_on → "state._shared.phase == \"scoring\""
An agent can wait for another agent:
GET /rooms/r/wait?condition=agents["agent-b"].status == "active"
Update presence: POST /rooms/r/agents/agent-a/heartbeat { "status": "busy" }
Agents support enabled for conditional existence:
POST /rooms/r/agents { "id": "path", "enabled": "has(state._shared.compass)" }
A disabled agent is invisible to other agents and to CEL contexts.
For counters that many agents update concurrently:
PUT /rooms/r/state { "key": "tasks_done", "value": 1, "increment": true }
No version conflicts — the server adds the delta atomically.
Setting up a room? → Steps 1-2 above.
Waiting for a condition? → Step 3. Use include to bundle state/agents/actions
in the response and avoid extra round-trips.
Writing state? → Step 4. Add if for semantic gates, if_version
for CAS, or both together.
Distributing tasks? → Post messages with kind: "task", workers claim.
See reference/examples.md for the fan-out/fan-in pattern.
Need derived/computed state? → Create a computed view in the _view scope.
See reference/cel.md for view patterns.
Need temporal lifecycle? → Add timer to any state write, action, or message.
Wall-clock (ms, at) for real-time. Logical-clock (ticks + tick_on) for
turn-based or latency-independent timing.
Need progressive disclosure? → Use enabled on agents, state, actions, or
messages. Resources appear and disappear as conditions change.
Need reusable operations? → Register actions with PUT /rooms/:id/actions.
Agents invoke by name. Params, predicates, cooldowns, and writes are declared once.
Debugging expressions? → POST /rooms/:id/eval { "expr": "..." } evaluates
any CEL expression against current state.
Viewing room state as human? → https://sync.parc.land/?room=<ROOM_ID>
Four layers, from coarse to fine:
-
Room isolation — rooms are the security boundary. Only agents who know the room ID can interact with it.
-
Bearer tokens — join returns a token that proves identity on mutations. Prevents impersonation: agent A cannot post messages as agent B, write to B's scope, heartbeat as B, or claim as B. Include via
Authorization: Bearer <token>. Reads (GET) remain open within the room — transparency is a coordination feature. -
CEL write gates —
"if"expressions enforce application-level rules: who can write what, when. The server evaluates the expression before allowing the write. -
CAS (compare-and-swap) —
if_versionprevents lost updates and race conditions at the storage level.
Tokens are optional (backward compatible). First-join-wins: once an agent ID has a token, only the token holder can re-register that ID (prevents identity hijacking). Re-joining rotates the token. Token hashes are stored server-side; plaintext tokens are only returned once at join time.
Auto-heartbeat: the server automatically updates last_heartbeat on
every meaningful action (messages, state writes, claims, action invocations).
No manual heartbeat calls needed to maintain presence. Agents with
status: "done" are excluded from auto-heartbeat.
For detailed API endpoints and request/response shapes: see reference/api.md
For CEL context shape, operators, computed view patterns, and common expressions: see reference/cel.md
For end-to-end coordination patterns (turn-based, fan-out/fan-in, consensus, pipeline, progressive disclosure): see reference/examples.md