---
slug: 60-agents
title: Multi-agent
description: Concurrent agents in Synsema coordinate through a thread-safe blackboard and signals — real threads, isolated interpreters, contained failures.
example_ids: [agents]
---

# Multi-agent

Agents run concurrently on **real threads**, each in its own isolated interpreter. They coordinate through a shared **blackboard** and **signals** — not direct calls. (This is the concurrency layer; for a model that picks tools, see **[Tool calling](/en/0.6.x/51-llm-tool-calling)**.)

```synsema
-- Doc example: the blackboard (share / observe) — thread-safe shared state.
-- spawn / signal / wait_for run real threads (non-deterministic), shown in the prose.
intent: "doc example: blackboard"

share 42 as "demo_key"
observe "demo_key" as demo_v
print("blackboard: demo_key → " + text(demo_v))

test "the blackboard is shared, synchronous state (share publishes, observe reads)"
    share 42 as "answer"
    observe "answer" as a
    assert_eq(a, 42)
    share "hi" as "result_1"
    observe "result_1" as g
    assert_eq(g, "hi")
```

## Define & spawn

Defining an agent **registers** it; the body runs only when spawned (in a new thread). The parent continues immediately.

```synsema
agent Researcher
    require net("*.wikipedia.org")
    let data be fetch("https://en.wikipedia.org/api/...")
    share data as "research"
    signal "done"

spawn Researcher with query = "AI safety"
```

Top-level tasks/values are **snapshotted** (a copy) into the agent. What the agent does **not** see: tasks defined inside imported modules (only the entry program's top-level bindings travel — re-export what the agent needs as a top-level task), and a task passed as a `spawn` argument arrives as **text** (closures do not cross threads: pass data). Each agent is a fresh interpreter with its own capability set — it needs its **own `require`** lines, bounded by the host ceiling. A failing agent is **contained** (state `ERROR`); `synsema run` joins agents before exiting and exits non-zero if any ended in `ERROR`.

**Testing agents.** `synsema test` wires the same real swarm as `run` (engine v0.6.10+): inside a `test` block `spawn` starts the agent in its own thread, `agents()`/`agent_stop` exist, blackboard and signals work. When the block ends the runner joins that block's agents; an agent that finished in `ERROR` fails **that** test (`Agent error [<id>]: …`) and the next block starts clean. On engines ≤ 0.6.9 `test` had no swarm (`spawn` ran the body in-process, blocking).

## Blackboard — `share` / `observe`

`share value as "key"` publishes; `observe "key" as var` reads. Thread-safe and versioned; the key is an expression (`"result_" + text(id)`).

## Signals — `signal` / `wait_for`

```synsema
signal "done"                       -- emit (a consumable queue, not a latch)
signal "done" with data             -- emit with a payload
wait_for "done" as result           -- blocks until a signal arrives (default 30s), CONSUMES it
wait_for "done" timeout 2 as r      -- bound the wait; returns nothing on timeout
```

Bound `wait_for` with `timeout` inside a route handler so a request can't hang. The channel is an expression — use `"cancel:" + text(job_id)` for a per-job push channel.

## Event bus — `bus_publish` / `bus_subscribe` (fan-out) — engine v0.6.7+

Signals are consumed by one receiver. When N parties must see the same event (every SSE/socket client of a live UI), publish on the **bus**: one per program, seen by the top level, `parallel_map` workers, cron ticks, spawned agents and every `serve` handler; in-process, bounded per subscriber, glob topics, no capability.

```synsema
bus_publish("agent.done", {"id": 7})          -- → subscribers reached
let sub be bus_subscribe("agent.*")
let ev be bus_recv(sub, 30)                    -- {type: "event", topic, data, timestamp} or nothing
```

Full semantics, `select` over sockets + processes + bus, and the SSE pattern: [Agentic apps](/en/0.6.x/47-agentic-apps).

## Observing and stopping agents — `agents()` / `agent_stop` — engine v0.6.7+

```synsema
agents()                  -- [{id, name, state, error, started_at, finished_at}]
agent_stop(id, reason?)   -- cooperative cancellation; true if it was alive → state "stopped"
```

A stopped agent raises `cancelled: <reason>` before its next statement and wakes from any wait (`wait_for`, `sleep`, `select`…). Works under `run` and `serve`, no capability. Under `serve`, a spawned agent gets the same wiring as a cron tick (`state_*`, DB, approvals, bus, memory), runs under the host ceiling (`serve --sandbox | --cap-set`), and is stopped by an ordered shutdown.

## Inspecting a run

```sh
synsema conform --swarm program.syn    -- JSON dump: blackboard + per-agent states
```
