agent/workbench

The workbench in examples/agent-workbench is an RLM chat app built around durable background work: it starts Lashlang processes and subagents, runs web tools, takes trigger button presses, registers Restate-backed cron triggers, and keeps the browser connected through app product rows plus Lash session observation live replay. Restate is required. The bundled just agent-workbench entrypoint starts it in Docker, registers the in-process endpoint through Restate Admin, then opens the browser.

Run It

Run from the repo root. The entrypoint checks for Restate ingress/admin on the configured ports; if they are not already up it starts restatedev/restate:1.7.0 with host networking, waits for it, launches the workbench and its in-process Restate endpoint, registers the endpoint, then opens the browser.

OPENROUTER_API_KEY=... just agent-workbench
# then open http://127.0.0.1:3030
TAVILY_API_KEY
Key for the web.search(...) and web.fetch(...) tools, matching the CLI web tools. Optional; the rest of the workbench runs without it.
OPENROUTER_MODEL
Default model id for new chats. Defaults to anthropic/claude-sonnet-4.6.
OPENROUTER_MODEL_VARIANT
Default reasoning variant. Defaults to high; choose provider default in the UI to send no variant for models without configurable thinking.
AGENT_WORKBENCH_ADDR
Browser bind address. Defaults to 127.0.0.1:3030.
AGENT_WORKBENCH_DATA_DIR
Persistence, plus the JSONL turn and Lashlang-execution traces. Defaults to .agent-workbench.

The entrypoint also manages the workbench's Restate endpoint bind (AGENT_WORKBENCH_RESTATE_ADDR, default 127.0.0.1:9081), the Restate container ports (ingress 8080, admin 19070, node 19071), and the Tokio worker stack (AGENT_WORKBENCH_TOKIO_STACK_BYTES, default 8 MiB / 8388608 bytes; override only when diagnosing stack regressions). See examples/agent-workbench/README.md for the full list.

What It Demonstrates

The browser UI has three work areas: a left rail of trigger buttons, a cron schedule card, and per-turn model controls; a center pane that switches between the chat/event stream and a mock multi-account inbox; and a right rail that polls the process registry for the session's granted background work.

Background processes

The model emits named Lashlang process declarations and start name(...); started processes appear in the right-rail registry view.

Process observation & retention

A best-effort ProcessEventSink wired through RestateProcessDeployment::new_with_sink streams appended process events to a logging task — freshness, never truth (ADR 0017). GET /api/work/{process_id}/await waits on a work item through ProcessWorkDriver::await_terminal (ADR 0016), bounded by a timeout, then reconciles the authoritative log from events_after.

Subagents

agents.spawn(...) runs child sessions as background process work, observed through the same registry and graph panel.

Web tools

web.search(...) and web.fetch(...) back onto Tavily, the same web tools the CLI exposes.

Trigger buttons

Red and blue buttons emit ui.button.pressed trigger occurrences; a registered trigger wakes a process with the validated event payload.

Mock inbox accounts

The accounts tab connects mock inboxes projected into the Lashlang host environment as typed inbox.<slug> modules; delivering a message emits mail.received, the demo's third trigger source beside the button and the cron clock.

Restate cron triggers

An enabled cron.Schedule registration syncs to a Restate virtual object that owns the timer and fires cron.Ticks.

Browser reconnect

/api/events merges workbench product rows with RemoteSessionObservationEvent rows from subscribe_and_recover_remote; the browser persists the latest cursor and reloads /api/state on replay_gap.

Restate turn control

User turns, queued catalog refreshes, button triggers, inbox deliveries, session deletion, and cron jobs submit through Restate ingress. Stop retains the exact session/turn address and calls TurnWorkDriver; its keyed-promise request and authoritative cancellation evidence survive reconstructing the web process. The workbench does not persist a second submitted/running work-item lifecycle.

Lashlang graph

The graph panel is backed by TraceLashlangGraphStore; command operations still go through the session's SessionProcessAdmin facade.

Browser Stream And Reconnect

The workbench keeps product state and Lash observation state separate on purpose. Product state is the durable app projection; live replay is the semantic session stream that lets a browser reconnect without making turn-local streaming a public resume protocol.

Restate owns in-flight workflow replay by turn_id, Lash owns the session execution lease and final turn commit, and the workbench app stream stores only user-visible product events plus reconnect cursors.

snapshot

/api/state returns the workbench's product snapshot: settings and messages. It does not expose a raw session timeline.

stream start

/api/events accepts an optional ?cursor=.... Without one, the route captures session.observe().current_observation().cursor, sends it as replay_cursor, and then subscribes. With one, it deserializes SessionCursor and resumes from that point.

stream rows

The response merges host-defined rows such as message, error, and done with Lash rows: observation carries a RemoteSessionObservationEvent, while replay_gap carries RemoteLiveReplayGap plus a RemoteSessionObservation snapshot.

gap recovery

On replay_gap, the browser stores gap.latest_cursor, reloads /api/state for the durable product projection, and reconnects from the latest cursor. Reset rotates the session id and starts a fresh stream.

Button Trigger Occurrences

The buttons emit a zero-config trigger source: the source config is {}, and red/blue selection arrives in the event payload. The model registers a trigger that wakes a process when a button is pressed.

process on_button(event: ui.button.Pressed) {
  wake { kind: "button_pressed", button: event.button, message: event.message }
  finish true
}

handle = await triggers.register({
  source: ui.button.pressed({}),
  target: on_button,
  inputs: { event: trigger.event },
  name: "button watcher"
})?
registrations = await triggers.list({ name: "button watcher" })?
finish { handle: handle, registrations: registrations }

Cron Triggers

The cron card is the schedule-reference integration: there is no Lashlang schedule syntax and no UI tick button. In this example, Restate owns the timer policy. The plugin declares the cron.Schedule source; Lashlang builds a cron.Schedule value and registers it with the runtime trigger registry.

process daily_digest(tick: cron.Tick) {
  wake { kind: "daily_digest_due", tick: tick }
  finish true
}

source = cron.Schedule({ expr: "0 8 * * *", tz: "UTC" })
handle = await triggers.register({
  source: source,
  target: daily_digest,
  inputs: { tick: trigger.event },
  name: "daily_digest"
})?
finish { handle: handle, registrations: await triggers.list({ target: daily_digest })? }

After a Restate-backed turn registers an enabled cron.Schedule, the workbench syncs that source key to WorkbenchCronJob/{session_id}:{source_key}. The virtual object stores the source request, the next execution timestamp, and the Restate invocation id in Restate K/V state. Its run handler first schedules the next run with a delayed Restate send (so a tick that fails cannot kill the schedule), then emits a validated cron.Tick trigger occurrence whose idempotency key includes the journaled fire time: unique per tick, stable across retries of the same tick. A job whose session is no longer the live workbench session terminates itself on its next fire instead of ticking into a deleted session. Resetting the workbench cancels the old session's cron jobs derived from its durable trigger registrations (plus anything armed in-process), and an equal-request re-sync revives a chain whose stored next execution is already in the past. The trace JSONL includes agent_workbench.cron.restate.sync_upserted, agent_workbench.cron.restate.run, and agent_workbench.cron.restate.zombie_cancelled events.

Host Wiring

The host declares source constructors such as cron.Schedule and mail.received in a lashlang::LashlangHostCatalog (assembled by workbench_lashlang_resources()) wired into the Lashlang surface. The button source is zero-config and exposed from trigger registration, whose payload type is a union of Red / Blue plus a message and timestamp.

fn field(name: &str, ty: lashlang::TypeExpr) -> lashlang::TypeField {
    lashlang::TypeField {
        name: name.into(),
        ty,
        optional: false,
    }
}

fn schedule_config_type() -> lashlang::TypeExpr {
    lashlang::TypeExpr::Object(vec![
        field("expr", lashlang::TypeExpr::Str),
        lashlang::TypeField {
            name: "tz".into(),
            ty: lashlang::TypeExpr::Str,
            optional: true,
        },
    ])
}

fn cron_tick_event_type() -> lashlang::NamedDataType {
    lashlang::NamedDataType::object(
        "cron.Tick",
        vec![field("fired_at", lashlang::TypeExpr::Str)],
    )
    .expect("valid cron tick type")
}

fn button_trigger_event_type() -> lashlang::NamedDataType {
    lashlang::NamedDataType::object(
        "ui.button.Pressed",
        vec![
            field(
                "button",
                lashlang::TypeExpr::Union(vec![
                    lashlang::TypeExpr::Enum(vec!["Red".into()]),
                    lashlang::TypeExpr::Enum(vec!["Blue".into()]),
                ]),
            ),
            field("message", lashlang::TypeExpr::Str),
            field("pressed_at", lashlang::TypeExpr::Str),
        ],
    )
    .expect("valid button trigger event type")
}

fn button_trigger_payload_schema() -> lash::triggers::LashSchema {
    lash::triggers::LashSchema::new(serde_json::json!({
        "type": "object",
        "properties": {
            "button": { "type": "string", "enum": ["Red", "Blue"] },
            "message": { "type": "string" },
            "pressed_at": { "type": "string" }
        },
        "required": ["button", "message", "pressed_at"],
        "additionalProperties": false
    }))
}

fn mail_received_event_type() -> lashlang::NamedDataType {
    lashlang::NamedDataType::object(
        "mail.Received",
        vec![
            field("account", lashlang::TypeExpr::Str),
            field("title", lashlang::TypeExpr::Str),
            field("text", lashlang::TypeExpr::Str),
        ],
    )
    .expect("valid mail received event type")
}

fn workbench_lashlang_resources() -> lashlang::LashlangHostCatalog {
    let mut resources = lashlang::LashlangHostCatalog::new();
    resources
        .add_trigger_source_constructor(
            ["cron", "Schedule"],
            schedule_config_type(),
            cron_tick_event_type(),
        )
        .expect("valid cron trigger source");
    resources
        .add_trigger_source_constructor(
            ["mail", "received"],
            lashlang::TypeExpr::Object(vec![]),
            mail_received_event_type(),
        )
        .expect("valid mail trigger source");
    resources
}

fn declare_button_event(reg: &mut PluginRegistrar) -> Result<(), PluginError> {
    reg.triggers().declare(TriggerEvent::new(
        "Button",
        "ui.button",
        "pressed",
        button_trigger_payload_schema(),
    ))?;
    Ok(())
}

Live Smoke

A model-free live check that starts Restate, registers the endpoint through Admin, submits a turn through Restate /send, waits for that invocation to complete successfully, schedules a fast cron, waits for a tick, and checks the JSONL trace.

just agent-workbench-restate-e2e

The recipe schedules cron.Schedule({ expr: "*/2 * * * * *", tz: "UTC" }), waits for the Restate virtual object to fire a cron.Tick, asserts the queued turn ran, verifies the submitted invocation completed successfully, asserts no Workbench* or LashProcessWorkflow invocation remains active, and removes the container on exit.

read on ·