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 observed 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/state materializes one authoritative snapshot and both cursors. /api/observations then carries Lash recoverable-chat updates while /api/events carries the durable, ordered product log. Either lane resynchronizes from state instead of exposing a raw stream error.

PNG attachments

The Workbench upload control is deliberately image/png-only and carries one content-addressed attachment through upload, turn input, provider materialization, and restart-safe retrieval. This is a UI boundary; provider transports enforce their own MIME allowlists in Lash.

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 product log persists user-visible events with stable ids and monotonic per-session positions.

snapshot

/api/state returns settings, the complete canonical Lash transcript merged by stable id with product-only rows, typed durable turn-input applications, a Lash observation snapshot/cursor, and a product-event snapshot/cursor. It does not expose a raw session timeline.

stream start

/api/observations resumes Lash from the opaque cursor through subscribe_recoverable_chat. /api/events independently resumes the product log from its numeric position. The initial state response prevents a snapshot/attach race in both lanes.

stream rows

The product response carries identified message, turn_input, and turn-scoped done events or an authoritative resync. A completed turn retracts only its own provisional rows, preserving output from newer turns. It has no raw-error variant. The Lash response carries observation, replay_gap, and terminal_replacement.

gap recovery

On replay_gap, product lag, cancellation settlement, or terminal replacement, the browser reloads /api/state behind a generation and product-cursor fence. A response overtaken by a newer recovery or live product event is discarded; an accepted authoritative replacement rebuilds both dedup sets and assigns its cursors. Reset rotates the session id and starts both streams from session-scoped cursor state.

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 cron remains valid while its owning session has durable store metadata, including when another workbench session is current. A job whose session has a permanent deletion tombstone cancels with reason: "session_retired"; a job with neither a tombstone nor session store metadata cancels as an orphan with reason: "session_absent". 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 disposition read and cancel trace are Restate-journaled. Tick and zombie-guard traces record session_state: "live", "retired", or "unknown". The trace JSONL includes agent_workbench.cron.restate.sync_upserted, agent_workbench.cron.restate.run, and agent_workbench.cron.restate.zombie_cancelled events.

Durable Approval Is Host Policy

The workbench's ops.apply_change demo takes a ToolContext::completion_key(), persists it in the host-owned approvals.db, and returns ToolOutcome::Pending. The operator rail lists the key, tool, arguments, requesting session, and age. Approve and deny actions resolve that same durable key through LashCore::completions(), so an approval wait survives loss of the workbench process.

Approval is deliberately not a Lash manifest capability. It is host policy: the host chooses gated tools, authenticates operators, stores pending decisions, and defines their outcomes. Lash core will never grow an approval flag or approval/revert surface; its only responsibility is the general completion-key park/resume primitive.

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 ·