runably sdk

A complete agent runtime for Rust,
open at every layer.

Embeddable into any language, with official SDK
Python Node.js Java .NET Go and more

Providers, agents, tools, storage and telemetry all ship as working implementations, each behind one of seven ports. Substituting your own means implementing that port’s trait and passing it to the builder — the loop itself never changes.

cargo add runably-sdk Coming soon!
main.rs
use runably_sdk::{RunablySDK, AgentId, PermissionMode};

// Every seam is optional — bring only what you need.
let sdk = RunablySDK::builder()
    .with_provider(anthropic)
    .with_toolsets(default_tools())
    .with_storage(backend)
    .build();

let session = sdk
    .create_session(workspace, "refactor".into(), PermissionMode::Default)
    .await?;

// Streams tokens, and dispatches each tool the moment its arguments land.
let run = sdk
    .send(&session, conversation, AgentId::from("code"), prompt, model)
    .await?;

assert!(matches!(run.exit, ExitReason::Completed));
7
pluggable ports
16
LLM providers
13
built-in agents
100%
line coverage
Architecture

Four layers, one closed boundary

Runably SDK is ports and adapters, taken literally. Dependencies point strictly inward — infrastructure knows about features, features know about the kernel, and the kernel knows about nothing but itself. The SDK facade sits outside all three as the composition root.

Outermost
dependencies point inward →→→
Kernel
Facade runably-sdk the single crate a host may name — every other crate is publish = false
Infrastructure shared adapters and drivers
log/tracingstorage/inmemorystorage/blobfsllm/defaultllm/mockprovider/catalog/defaultprovider/catalog/mock
Features vertical slices, each internally hexagonal
agent
prompt
session manager · runtime · view
provider catalog · request-identity · service · oauth
workspace
plugins compaction · queue
workflows fanout · review
tools core · mcp · lsp · code-index · skill · subagent
Kernel the hexagon interior — depends on nothing outward
kernel/models

the neutral, SDK-owned domain

typed ID newtypesPart / TranscriptStreamEventExitReasonToolOutcomeWaitForInputSystemPromptAgentDefinition
kernel/loop · engine · workflow

the pure loop, its composition root, and multi-agent orchestration

StreamingLoopEngine + EngineBuilderRegistry / Pluginworkflow
kernel/ports

seven leaf crates, each depending inward on runably-models alone

runably-llmrunably-toolrunably-storagerunably-eventrunably-hooksrunably-inputrunably-log

The boundary is enforced, not documented

A consumer names everything through runably_sdk:: and never depends on an inner crate. That is checked in CI by scripts/lint_sdk_boundary.sh, which greps every host Cargo.toml for runably-* dependency keys and fails on any that is not runably-sdk. The seven port crates are leaves — each depends inward on runably-models only — which is what makes them safe to implement without dragging in the engine.

A host supplies only the storage implementation; the runtime derives every role-store from it. It never picks individual stores apart for the host.
sdk/src/lib.rs
engine.rs
let engine = EngineBuilder::new(provider, store.clone())
    .with_permission(permission)
    .with_tool(tool)
    .with_reader(store.clone())
    .with_wait_store(store)
    .with_config(LoopConfig {
        system: "you are a coding agent".into(),
        ..Default::default()
    })
    .build();

build() is infallible — everything not supplied falls back to an inert default. Note that one Arc<InMemoryStore> satisfies three different role ports here (MessageWrite, MessageRead, WaitStateStore); segregating the ports costs the implementor nothing and buys every consumer a narrower dependency. Omit with_reader and with_wait_store and the engine degrades to a pure in-memory loop with no durable resume — resume is opt-in by construction.

How contributions compose

The builder collects tools, hooks and event sinks into a registry, then folds them into the loop's single-object seams at build().

Plugin

A named bundle of contributions: name() plus register(&mut Registry). A plugin owns what it contributes; the engine owns how the contributions compose.

Registry

An ordered mutable bag — add_tool, add_hook, add_event_sink. Insertion order is preserved all the way into the folded wiring.

HookChain

Folds N LoopHooks into one. The same &mut accumulator is threaded through each in registration order, so hook N observes the mutations of hooks 0..N.

EventFanout

Folds N EventSinks into one, forwarding a clone of every event to every subscriber. Empty degrades to the inert default.

The loop

Memory-first, and eager

StreamingLoop owns the authoritative transcript in RAM and never re-reads storage mid-run — storage is a write-behind sink, not the loop's working state. It consumes structured streaming tool events, which lets it dispatch a tool the instant that call's arguments finish arriving, while the rest of the turn is still streaming.

One turn, four tool calls

Every call is dispatched the moment its arguments complete. Execution is what gets gated.

model stream executing dispatched, waiting on the gate
model stream
one turn
read · src/main.rs
read permit
search · "impl Tool"
read permit
list · kernel/ports
read permit
edit · Cargo.toml
write permit
turn start turn end

The three concurrency-safe calls overlap each other and the stream. The edit call is dispatched just as eagerly, at roughly the halfway mark — but it is not concurrency-safe, so it waits for the exclusive permit and only starts once the safe calls have released theirs.

Eager, but gated

Dispatch is always eager; execution is gated by a per-turn tokio::sync::RwLock<()>, fresh for each turn and shared by every call dispatched in it. A tool that declares is_concurrency_safe takes a read permit — many at once, so safe calls genuinely run in parallel with each other. An unsafe call takes a write permit, held exclusively for the call's full invocation including its internal auto-retries.

tokio::sync::RwLock is write-preferring, so a queued writer is never starved by a stream of readers arriving behind it. This was chosen over holding unsafe calls out of the JoinSet entirely: the gate is just another await point inside the same spawned future, so it needs no turn-level draining logic and composes for free with cancellation and abort-on-retry.

Dispatch pipeline
  1. 1
    pre_tool hooks
    the influence seam runs first, ahead of any policy
  2. 2
    workspace gate
    a tool requiring a filesystem root is refused in a workspace-less session
  3. 3
    agent tool-policy gate
    deny-list, allow-list, then the read-only flag
  4. 4
    permission-resource classification
    the tool maps its own args to a typed resource, once
  5. 5
    PermissionPort::check
    allow, deny, ask, or force-reject
  6. 6
    spawn into the turn's JoinSet
    the call becomes a task; the stream keeps flowing

Prompt assembly

Tools are built into the prompt on every call and never cached on the client, so the offered tool set can change from one turn to the next. model_facing_messages filters out every Part::Error on the way out: a run's terminal error is stored so the user can see it, but it is never replayed to the model as if the model had produced it.

A closed, provider-neutral stream

The LlmClient yields one enum, whatever the vendor. Each adapter normalises its own wire format into this set at its own boundary.

CreatedTextDeltaReasoningDeltaToolCallStartToolInputProgressToolInputParamToolCallChunkToolCallDeltaUsageDeltaCompletedError

Early parameters

Tool::on_early_param(key, value) -> EarlyParamEffect is called once per top-level argument as it parses, before the call is complete. The tool decides what that key means — which is what lets a UI show "Reading src/main.rs…" while the rest of the arguments are still arriving, and lets a tool reject a call on a deterministic policy failure without ever running it.

IgnoreSetDisplayCaptureSnapshotRejectRequestInput
Pause

A pause is an exit, not a suspended future

When a tool needs input — a permission ask, an error retry, a parked subagent — the loop exits with ExitReason::WaitingForInput and drops its in-memory transcript. There is no task left parked in memory holding state. The engine records durable wait tokens keyed by part id; resume reloads the transcript from storage — the database is truth — and re-enters the pure loop.

Resume

Exactly-once, by compare-and-swap

WaitStateStore::try_claim_wait_batch is all-or-nothing: a concurrent batch observes either every token present or every token consumed, never half. A caller therefore never holds an already-won token it would have to compensate for. Resume splits into claim_resume — the unguarded CAS — and drive_resume — the guarded loop drive — so a caller can persist an approval rule only when its answer actually won the race.

Three ways in, one way through

All three entry points funnel into the same private drive(), so there is exactly one implementation of the turn loop to reason about.

run(RunParams)

a fresh run — the user message is the first turn over an empty transcript

continue_from(conv, transcript, input)

add a turn to pre-loaded history; the turn budget is per send

resume_from(conv, transcript, answers)

apply answers to paused waits, then continue if any answer advanced the run

Ports

Seven seams, seven inert defaults

Each port is a leaf crate with a minimal trait surface and a default implementation that does nothing. You attach only the seams you need, and everything you leave alone stays quietly out of the way rather than forcing a stub.

runably-llm
LlmClientLlmResolver

One model turn as a neutral event stream. Implementations own all provider specifics — wire serialization, auth, retry and backoff, model fallback — and the loop sees none of it. Dropping the stream cancels the request.

NullResolver
runably-tool
ToolToolBoxPermissionPortRecoveryPort

What the agent can do, and whether a given call may do it. The tool trait is deliberately tiny; permission and recovery are separate policy seams checked before a call runs and after it fails.

AllowAll · AskAlways
runably-storage
11 role traits

Write-behind sinks plus restart and UI read sources, split fine enough that a component depends only on what it actually uses. One concrete backend implements all eleven.

NoAttachments
runably-event
EventSink

Observation fan-out. emit is synchronous and non-blocking by design — the loop must never block on a subscriber, so a real sink forwards into a channel and returns at once. SessionEvent is a closed 17-variant enum.

NoEvents
runably-hooks
LoopHooks

Influence, not observation. Every method takes a &mut accumulator and returns nothing: no short-circuit, no Err. The chain is a deterministic fold in which every hook runs.

NoHooks
runably-input
UserInputPort

A tool asking the user a question mid-run. The implementation owns the whole lifecycle — id assignment, UI projection, reply routing, cancellation; the loop and its tools only ever see the one method.

NoInput
runably-log
— no trait —

A typed vocabulary (Component, RunablyScope) plus log_info! / session_span! macros. Logs are native tracing events rendered by the host's process-global subscriber.

no subscriber
The key split

Observation and influence are different seams

Most agent frameworks fuse them into one callback list, which means anything that wants to watch a run also has the power to change it. Runably SDK splits them, and the split is what makes a subscriber safe to add.

runably-event Observation
Events are observation, not influence… many subscribers may watch a run — UI, logging, telemetry — but none can change it.

fn emit(&self, event: SessionEvent) — no return value, no back-pressure, no await. Fan-out is unbounded because it is provably harmless: an extra subscriber cannot alter what the run does, only how slowly its own channel drains.

runably-hooks Influence
Hooks are influence, not observation… The contract is three points — pre_tool, post_tool, on_stop — and nothing else.

Three points, deliberately: lifecycle belongs to events, so there is no on_run_start to reach for. A hook reports trouble by recording a HookError on the accumulator; a panic mid-hook is caught by the chain and turned into a fatal one.

Guarded on post_tool

A rewrite may not change whether the call pauses the loop, so a rewrite that adds or removes a WaitForInput is ignored.

runably-tool

Eight methods, five of them optional

Tool carries only what the loop must know to execute a call safely. UI rendering, MCP metadata and search hints are not on it — those belong to optional capability traits. Only name, spec and invoke are required; the classification hints default defensively.

namespecis_read_onlyis_concurrency_saferequires_workspace_rooton_early_parampermission_resourceinvoke
PermissionDecision
Allow { updated_args }
run it, optionally with rewritten arguments
Deny { message }
model-facing feedback; the run continues so the model can pick another action
Ask(WaitForInput)
escalate to a human; the loop pauses on the wait
ForceReject { message }
kill switch — the run ends with an error at the turn boundary
RecoveryDecision
Retry { option }
re-invoke, optionally selecting a named retry option
Continue
accept the error and carry on
Fail { reason }
make the error terminal
Ask
escalate; the loop pauses on the wait
read_tool.rs
use async_trait::async_trait;
use runably_models::{PermissionResource, ToolOutcome, ToolSpec};
use runably_tool::{EarlyParamEffect, Tool, ToolCtx};
use serde_json::{json, Value};

pub struct ReadTool;

#[async_trait]
impl Tool for ReadTool {
    fn name(&self) -> &str {
        "read"
    }

    fn spec(&self) -> ToolSpec {
        ToolSpec {
            name: "read".into(),
            description: "Read a UTF-8 file from the workspace.".into(),
            input_schema: json!({
                "type": "object",
                "properties": { "path": { "type": "string" } },
                "required": ["path"]
            }),
        }
    }

    // is_concurrency_safe defaults to is_read_only, so this call takes a
    // shared read permit and runs alongside its siblings.
    fn is_read_only(&self) -> bool {
        true
    }

    fn requires_workspace_root(&self) -> bool {
        true
    }

    // Called as each top-level argument parses, before the call completes.
    fn on_early_param(&self, key: &str, value: &Value) -> EarlyParamEffect {
        match (key, value.as_str()) {
            ("path", Some(path)) => EarlyParamEffect::SetDisplay(path.to_string()),
            _ => EarlyParamEffect::Ignore,
        }
    }

    fn permission_resource(&self, args: &Value) -> PermissionResource {
        match args.get("path").and_then(Value::as_str) {
            Some(path) => PermissionResource::read_path(path),
            None => PermissionResource::custom("read"),
        }
    }

    async fn invoke(&self, args: Value, ctx: &ToolCtx) -> ToolOutcome {
        // Never cwd().unwrap(): the loop's gating keeps a workspace-less call
        // from getting here, and this returns a typed error if it ever does.
        let root = match ctx.require_cwd() {
            Ok(root) => root,
            Err(e) => return ToolOutcome::error(e.to_string()),
        };
        let Some(path) = args.get("path").and_then(Value::as_str) else {
            return ToolOutcome::error("`path` is required");
        };
        match tokio::fs::read_to_string(root.join(path)).await {
            Ok(text) => ToolOutcome::success(text),
            Err(e) => ToolOutcome::error(format!("read failed: {e}")),
        }
    }
}

A complete tool, start to finish. Nothing here reaches the engine, the session, or storage — a leaf tool receives only the narrow per-call ToolCtx, which is the deliberate opposite of a fat context object.

Tools

File tools with boring guarantees

Five file tools ship in the box. Each one is bounded in memory, cancellation-aware, atomic where it writes, and honest about the point where it stopped. A tool that quietly truncates or silently returns nothing is worse than one that fails, because the model has no way to tell the difference.

read
Bounded windows over a file
path offset ? limit ?

Returns a 1-based line-numbered window over a file. The content is streamed line by line and never loaded whole, so a multi-gigabyte log costs the same resident memory as a config file.

  • Default window of 2000 lines; offset and limit page through anything larger.
  • A 10 MiB retained-byte cap bounds the window regardless of the requested line count.
  • A line cut short by the cap is flagged incomplete rather than silently truncated, so the model is never handed a half line that looks whole.
write
Atomic create or overwrite
path content

Creates a file or replaces one wholesale. Missing parent directories are created as part of the call.

  • Atomic: content lands in a temp file in the same directory, then a rename swaps it in.
  • Same-directory staging keeps the rename on one filesystem, so it stays a single atomic operation.
  • A concurrent reader observes either the old file or the new one, never a partial write.
edit
Exact replacement with a staleness guard
path old_string new_string replace_all ?

Exact substring replacement. There is no fuzzy matching and no similarity threshold: without replace_all, old_string must match exactly once or the call fails.

  • Atomic write over the same temp-file-and-rename path as write.
  • A process-global per-path lock spans the whole sequence: read, compute, re-read, byte-compare, replace.
  • An external mutation landing between the read and the write surfaces "File changed on disk since it was read" instead of clobbering someone else’s edit.
list
Directory listing that stops early
path ? glob ?

Lists files and directories. Non-recursive by default; supplying a glob switches the walk to recursive and filters as it goes.

  • Honours .gitignore, so build output and vendored trees stay out of the context window.
  • Caps at 500 entries during the walk with early termination — the cap is a stop condition, not a slice applied afterwards.
  • Cancellation is checked on every iteration, so an aborted turn does not leave a walk running.
search
Regex search with hard memory ceilings
pattern path ? glob ?

Regex content search across the workspace. Pure Rust with no external process to shell out to, and the entire walk runs inside spawn_blocking so it never stalls the async runtime.

  • Honours .gitignore and caps at 200 matches.
  • 500 characters per reported line and 1 MiB read per line, so a minified bundle on one line cannot balloon memory.
  • A genuine filesystem error surfaces as an incomplete result carrying the error, never as a silent "no matches".

One contract, two audiences

Every tool returns two things from a single call: a model-facing content string, and a structured, kind-tagged JSON data payload. The model reads the text; your UI reads the payload and renders a diff card, a file tree or a match list instead of a wall of monospace.

All five declare requires_workspace_root(), which the loop reads before assembling the tool list. A session without a workspace root never sees them, and no individual tool re-implements that check or invents its own error message for it.

Permissions are typed

Classification never matches on a tool name. Each tool declares its own permission_resource, and proxied MCP tools classify as PermissionResource::Mcp { server, tool } — so a policy can allow one server, one tool on that server, or none, without pattern-matching strings.

tools.rs
#[async_trait]
impl Tool for ReadTool {
    fn name(&self) -> &str { "read" }

    /// Declared once, enforced centrally: the loop drops every
    /// workspace tool from a session that has no workspace root.
    fn requires_workspace_root(&self) -> bool { true }

    /// Typed classification. Never a string match on the tool name.
    fn permission_resource(&self, args: &ReadArgs) -> PermissionResource {
        PermissionResource::FileRead { path: args.path.clone() }
    }

    async fn execute(&self, ctx: &ToolContext, args: ReadArgs) -> ToolResult {
        let window = read_window(ctx, &args).await?; // streamed, 10 MiB retained cap

        Ok(ToolOutput::new(window.render_numbered())  // what the model reads
            .with_data(ToolData::Read {               // what the UI renders
                path: args.path,
                start_line: window.start,
                truncated: window.hit_cap,
            }))
    }
}
Toolsets

The rest of the tool surface

Each toolset is its own crate and its own dependency. Take the ones you want; the loop only sees the Tool implementations you registered.

MCP
runably-tools-mcp

A Model Context Protocol client. It connects to MCP servers and proxies their tools in as ordinary runably-tool::Tool implementations, so at the loop boundary an MCP tool is indistinguishable from a native one.

LSP
runably-tools-lsp

Language-server-backed diagnostics and symbol lookup. The model asks the same compiler front end your editor asks, instead of inferring types from text.

Code index
runably-tools-code-index

A semantic index over the workspace plus the search tooling that reads it, for the queries a regex cannot express.

Skills
runably-tools-skill

Loads skills and invokes them, turning a packaged procedure into something the model can call by name.

Subagents
runably-tools-subagent

The agent tool: one call that hands a scoped task to a child agent. Covered in depth further down.

Agents

Thirteen agents, and a way to compose them

The built-ins ship as persisted definitions, not as hardcoded match arms, so an application can edit one, add its own, or narrow the catalog a given caller sees. An agent is a prompt, a model binding and a tool profile — the loop treats all of them identically.

code
Writes and modifies code
plan
Designs the change before it is written
ask
Read-only question answering
explore
Maps unfamiliar parts of a codebase
general
Broad implementation work
compaction
Summarises context when the window fills
search
Retrieval across the workspace
fetch
Retrieval from outside it
worker
Mechanical, well-specified edits
smart
The deepest reasoning budget
pilot
Autonomous multi-step execution
review
Read-only review of a change
advisor
A second opinion on a decision
Subagents

An agent is just another tool

One tool, named agent. A child subagent is a separate conversation inside the same session, driven through the normal SessionManager::send path — not a parallel execution mode with its own rules. Nesting is bounded by MAX_SUBAGENT_DEPTH = 3.

  1. 1
    Validate the call

    The depth guard runs first, then the prompt is checked for emptiness, then the agent id is mapped once into a typed AgentId and checked against the catalog this caller can actually see. Nothing downstream re-parses that id from a string.

  2. 2
    Derive the child conversation id

    Deterministically, from the parent conversation plus the tool call id. The same call always addresses the same child, so a replay resolves the existing conversation instead of forking a second one.

  3. 3
    Spawn detached

    The child run is fired through the SubagentSpawner seam, detached from the parent task. It is an ordinary run through SessionManager::send — the same streaming, the same persistence, the same inspectability.

  4. 4
    Park the parent

    The tool returns immediately with a wait, rather than blocking a task on a child that may take minutes. The parent turn suspends with a typed reason instead of holding a thread.

  5. 5
    Deliver the terminal result

    When the child finishes, SubagentBridge delivers its terminal result into the parent’s paused tool call. If that parent is itself parked inside a grandparent, the cascade recurses upwards.

Parking, precisely

Step four returns ToolOutcome::WaitForInput(WaitForInput::new(WaitKind::AwaitingSubagent, …)). The wait is typed, so the runtime knows what the turn is waiting on and can report it, resume it or cancel it without inspecting any strings.

Typed failure taxonomy
Completed Success
Error / MaxTurns / Timeout / ContextLimit Error
Interrupted Aborted

A mapping over variants, never over an error message.

Two failure modes worth naming
  • A failed spawn returns a tool error instead of parking. Parking with no child to deliver a result would strand the parent forever.
  • Interrupting a parent tears down its whole child subtree with cancel semantics, so grandchildren are never orphaned behind a parent that no longer exists.
Workflows

Orchestration in Rust, not in YAML

A workflow is a Rust type implementing Workflow, statically registered into a WorkflowRegistry. There is no serialized DSL to learn, no interpreter to debug, and no gap between what the graph says and what it does: control flow is control flow, and the compiler checks it.

Workflow

A trait with two members: name(&self) -> &str and async execute(&self, ctx: &WorkflowContext, input: WorkflowInput) -> WorkflowOutcome. Outcomes are Completed { text }, Failed { message } or Interrupted.

AgentInvoker

The single seam between a workflow and whatever actually runs an agent. Swap in a scripted invoker and an entire orchestration graph is testable without a network call.

WorkflowContext

Hands the author invoke_agent(), fanout() and descend(), threading depth and cancellation automatically so no workflow has to remember to propagate either.

WorkflowBudget

max_concurrency defaults to 3, max_depth defaults to 3 and is the recursion guard, and an optional per-invocation timeout bounds any single agent call.

AgentRunOutcome

Completed(AgentResult { text, usage }), Suspended(SuspendedRun), Failed(AgentFailure) or Interrupted. Suspension is modelled explicitly instead of being folded into success or failure, so orchestration policy can tell "not done yet" from "done".

merge_concat

Merges results as verbatim concatenation under numbered headers. There is deliberately no LLM reducer: an LLM-driven summary is just another invoke_agent call, written by the workflow author who knows what the summary is for.

Fan-out

Concurrent invocations are bounded by a Semaphore, outcomes are ordered by input index, and every slot is filled exactly once — a failure leaves a recorded failure in its position, never a hole you have to align by hand.

CollectAll
Every invocation runs; every outcome is returned, failures included.
FailFast
The first failure cancels the remainder.
BestEffort { minimum_successes }
Succeeds once the threshold is met, and reports the shortfall when it is not.

FanoutStatus also carries FanoutStatus::RejectedZeroConcurrency. A budget of zero is rejected outright rather than clamped to one, so a misconfigured budget surfaces at the call site instead of masquerading as a serial run that quietly costs three times as long.

audit_workflow.rs
use runably_workflow::{
    merge_concat, AgentRunOutcome, FanoutPolicy, FanoutStatus, Workflow,
    WorkflowContext, WorkflowInput, WorkflowOutcome,
};

/// Three read-only agents inspect the same change, then one agent applies it.
pub struct AuditWorkflow;

#[async_trait]
impl Workflow for AuditWorkflow {
    fn name(&self) -> &str { "audit" }

    async fn execute(
        &self,
        ctx: &WorkflowContext,
        input: WorkflowInput,
    ) -> WorkflowOutcome {
        // Bounded by WorkflowBudget::max_concurrency. Outcomes come back ordered
        // by input index, with every slot filled exactly once.
        let fanout = ctx
            .fanout(
                vec![
                    (AgentId::from("review"), input.ask("correctness and regressions")),
                    (AgentId::from("advisor"), input.ask("architectural fit")),
                    (AgentId::from("explore"), input.ask("blast radius")),
                ],
                FanoutPolicy::BestEffort { minimum_successes: 2 },
            )
            .await;

        // An explicit rejection, not a silent clamp to serial execution.
        if matches!(fanout.status, FanoutStatus::RejectedZeroConcurrency) {
            return WorkflowOutcome::Failed {
                message: "budget allows no concurrent invocations".into(),
            };
        }

        // Verbatim concatenation under numbered headers. Want a summary instead?
        // That is one more invoke_agent call, written by you.
        let merged = merge_concat(&fanout.outcomes);

        match ctx.invoke_agent(AgentId::from("code"), input.apply(merged)).await {
            AgentRunOutcome::Completed(result) => {
                WorkflowOutcome::Completed { text: result.text }
            }
            // "Not done yet" is not "done": the suspended run carries its session,
            // its conversation and its live waits, so it is genuinely answerable.
            AgentRunOutcome::Suspended(run) => WorkflowOutcome::Failed {
                message: format!("awaiting input on {}", run.conversation_id),
            },
            AgentRunOutcome::Failed(failure) => {
                WorkflowOutcome::Failed { message: failure.message }
            }
            AgentRunOutcome::Interrupted => WorkflowOutcome::Interrupted,
        }
    }
}
Providers

Sixteen providers, four subscription sign-ins

Provider identity travels as a stable text id over a static provider table, not as an enum. Adding an endpoint is a table row, not a breaking change to a public type, and an id read from a config file that predates the current build still resolves.

Anthropic
anthropic
API key or Claude subscription
OpenAI
openai
API key or ChatGPT subscription
Google
google
API key or Code Assist
GitHub Copilot
github-copilot
Copilot seat
Groq
groq
API key
DeepSeek
deepseek
API key
xAI
xai
API key
Cohere
cohere
API key
Together
together
API key
Fireworks
fireworks
API key
Nebius
nebius
API key
Zhipu
zhipu
API key
Ollama
ollama
Local runtime
LM Studio
lmstudio
Local runtime
OpenAI-compatible
openai-compatible
Any compatible endpoint
Test
test
Scripted, offline
Sign in with a subscription

Four first-class OAuth flows

A seat someone already pays for is a valid credential. Each flow is implemented against the provider's real behaviour, including the parts that are awkward: fixed ports, forwarder-only redirect URIs, and onboarding calls that must complete before the first request can be billed.

Anthropic
Claude Pro / Max
  • PKCE with S256 against the Claude Code public client id.
  • The user pastes the returned code back into the app; the pasted state is discarded in favour of the state the server tracked, so a doctored paste cannot smuggle in a foreign session.
  • Fetches the account UUID after exchange, so usage attributes to the right billing account.
  • Tokens refresh automatically.
OpenAI
ChatGPT Plus / Pro (Codex)
  • Auto-loopback on the fixed port 127.0.0.1:1455, with a 64-byte PKCE verifier.
  • The listener binds at the start of the flow rather than the end, so a port conflict fails before the user authorises instead of after.
  • The account id is read from the id_token JWT returned with the exchange.
  • Tokens refresh automatically.
Google
Gemini Code Assist
  • access_type=offline plus prompt=consent, so a refresh token is guaranteed rather than hoped for.
  • Runs the full Code Assist onboarding: loadCodeAssist, then an onboardUser poll loop until the tier is provisioned.
  • Resolves the Cloud project_id that subsequent requests are billed against.
  • Tokens refresh automatically.
GitHub Copilot
Individual / Business / Enterprise seat
  • Auto-loopback on a random port via the vscode.dev/redirect forwarder.
  • GitHub accepts exactly one redirect URI for the VS Code client, so the flow carries the localhost callback URL through the state parameter, behind a validated nonce.
  • The nonce is checked on the way back, so the callback cannot be redirected somewhere else.
  • No refresh path is needed: the issued token carries no expiry.

Refresh, and who owns which error

OAuthRefresher does single-flight per auth_id with a 300-second skew window, so a burst of concurrent requests produces one refresh, not one per request. The proactive path refreshes ahead of expiry. The reactive path — the one a 401 triggers — keys on token identity rather than expiry: if a peer already rotated the credential, it hands back the stored token without touching the provider.

Mid-run 401s are handled by a RefreshingLlmClient decorator, and the layering rule is strict: no error variant is retried at two layers. The LLM client retries overload and rate-limit and always propagates Unauthorized; the decorator handles Unauthorized and nothing else. Retry budgets stay legible because exactly one layer owns each failure.

refreshing_client.rs
#[async_trait]
impl LlmClient for RefreshingLlmClient {
    async fn send(&self, req: LlmRequest) -> Result<LlmResponse, LlmError> {
        match self.inner.send(req.clone()).await {
            // Overload and rate-limit are retried *inside* the client and never
            // reach this layer. Unauthorized is always propagated to here.
            Err(LlmError::Unauthorized) => {
                // Single-flight per auth_id, 300s skew window. The reactive path
                // keys on token identity, not expiry: if a peer already rotated
                // this token, the stored one comes back without a provider call.
                let token = self
                    .refresher
                    .refresh_reactive(&self.auth_id, req.token_used())
                    .await?;

                self.inner.send(req.with_token(token)).await
            }
            result => result,
        }
    }
}
Model catalog

DefaultProviderList fetches the catalog from https://models.dev/api.json and parses it loosely on purpose. One malformed model row is skipped, not fatal — an upstream typo in a model nobody uses cannot take down the whole picker.

Deterministic request identity

Per-request device, session and machine UIDs are derived by SHA-256 from the AuthId. They are stable across restarts and fingerprint the OAuth credential only, so replaying a run produces the same identifiers without profiling the host.

Testing

Test a full agentic loop, deterministically

Tool dispatch, permission gates, subagent delegation, pause and resume — all of it runs offline and repeatably, behind a 100% line-coverage gate. The SDK’s own cross-crate integration suite is 17 test files and roughly 4,000 lines, and every one of them runs on this mock.

MockLlmClient is a mockito-style scriptable LlmClient. It is both a stub — it replays scripted responses — and a spy — it asserts what the loop actually sent.

17
integration test files
~4,000
lines on the mock
0
sockets opened
mock.rs
use runably_llm_mock::MockLlmClient;
use serde_json::json;

let provider = MockLlmClient::builder()
    .turn(|t| { t.reasoning("thinking").tool_call("c1", "read", json!({"path": "x"})); })
    .turn(|t| { t.text("done").completed(Some(true)); })
    .build();

Two declaration styles, one internal model

.turn(..) appends a sequential turn, consumed exactly once in registration order. .when(matcher).reply(..) registers a matcher rule, optionally capped with .times(n). On each call the first stub that both matches and has uses left wins; if none does, .default_reply(..) fires.

matchers.rs
let provider = MockLlmClient::builder()
    .when(PromptMatcher::HasToolResult)
    .reply(|t| { t.text("after tool").completed(Some(true)); })
    .default_reply(|t| {
        t.tool_call("c1", "act", json!({})).completed(Some(false));
    })
    .build();
PromptMatcher
AnyLastUserContains(String)SystemContains(String)TurnIndex(usize)HasToolResultCustom(Arc<dyn Fn(&LlmPrompt) -> bool>)

Matchers read the outbound prompt, so a rule can key off the system prompt, the last user message, the turn index, or the presence of a tool result — which is usually the cleanest way to say “answer differently once the tool has run”.

TurnBuilder emits stream events

A turn is described in the same vocabulary the loop consumes, so a scripted turn and a real provider turn are indistinguishable downstream.

created() opens the turn
text(..) assistant text delta
reasoning(..) thinking delta
tool_call(call_id, name, args) a settled call with complete arguments
tool_call_early(call_id, name, key, value, args) surfaces one parameter before the argument stream finishes
usage(TokenUsage) token accounting
completed(Option<bool>) terminates the turn
completed_with_usage(..) terminates and accounts in one event
error(..) terminal error event
event(StreamEvent) raw escape hatch for anything not covered above

Three terminal overrides model the failure modes

Providers fail in more than one shape, and the loop treats each shape differently. Each override pins one of them.

open_error(kind, msg)

Fails at stream open.

Nothing is ever committed — the loop sees a dead provider before the first byte.

pending()

Opens a stream that never completes.

The shape you need to exercise deadlines and the per-step watchdog.

then_error(kind, msg)

Replays accumulated content, then yields a terminal error.

The only path that exercises the loop’s mid-stream continue-retry, because content is already committed when the failure lands.

The spy half

Assert what the loop sent, not just what it returned

Every outbound prompt is recorded. Times is Exactly(n), AtLeast(n) or Never.

received() every outbound prompt, in order
call_count() how many model turns the loop actually took
last_prompt() the most recent prompt as the provider saw it
times_matching(&matcher) count of prompts satisfying a matcher
verify(&matcher, Times) assertion over that count
continuation.rs
#[tokio::test]
async fn the_continuation_prompt_carries_the_tool_result() {
    let provider = tool_then_text("c1", "act", "done");
    let spy = provider.clone();
    let engine = engine_with(provider, store, tool, Arc::new(AllowAll));

    engine.run(run_params("conv", "go")).await;

    // Two model turns: the initial call and the post-tool continuation.
    assert_eq!(spy.call_count(), 2);
    // Exactly the second prompt includes the tool-result message.
    assert!(spy.verify(&PromptMatcher::HasToolResult, Times::Exactly(1)).is_ok());
}
Protocol invariants

verify_protocol_invariants()

An opt-in validator that folds over every recorded outbound prompt and asserts the merged-tool-part protocol. It is wired into the integration harness, so every loop test is pair-integrity-checked automatically — no test has to remember to ask.

  • Every Part::Tool is settled — terminal status, output present. This is the dangling tool_use that makes providers return a 400.
  • Every call_id is non-empty and unique within its issuing assistant message.
  • Tool parts only ever appear on assistant messages, never on user or system ones.
MockLlmClient::from_scenario_json(&str)

A serde Scenario format mirrors the builder for file-driven runs. Everything except PromptMatcher::Custom and pending() is expressible in JSON.

from_turns(Vec<Vec<StreamEvent>>)

For tests that already hold event vectors and want to hand them over without going through the builder at all.

Reliability

An agent loop fails in specific ways

Loops spin on the same tool call, hang on a silent stream, duplicate work when a provider drops mid-response, and take the process down when a subscriber panics. Each guardrail below exists because of one of those.

Cyclic tool-call detection

Deterministic k-periodic detection over a bounded ring

A 25-entry ring of call fingerprints is scanned for periods k = 1..5 across five repeats. Fingerprints hash an injective canonical form — tool name plus sorted, length-framed arguments — so key order does not matter and no key/value aliasing is possible.

1

First detection injects a single Role::System steering message and continues — one free recovery.

2

Second detection hard-stops the run with ExitReason::Error.

Detection runs on both the normal turn path and the mid-stream-retry turn path, so a retried turn cannot launder a repeat past the counter.

Mid-stream retry

Never restart a turn that already committed content

A retryable error that lands after content was committed cannot be retried inline — that would duplicate text and double-run tools. The loop finalises the partial turn, backs off, and re-runs.

5s 10s 15s fixed, not exponential 3 attempts, then terminal

is_mid_stream_retryable is an exhaustive match with no wildcard arm, so adding an error variant is a compile error rather than a silent decision to retry it.

Timeouts

Three independent tiers, three different meanings

All three live on LoopConfig and are Option<Duration>, so each is off until you set it. They are not layers of the same clock — a hung stream, a long-but-healthy turn and a runaway tool are distinct failures and produce distinct exits.

config.rs
let config = LoopConfig {
    total_deadline: Some(Duration::from_secs(300)),
    per_step_timeout: Some(Duration::from_secs(30)),
    per_tool_timeout: Some(Duration::from_secs(60)),
    ..Default::default()
};
total_deadline Whole-run wall clock

Enforced as a separate sleep_until future in the drive select — deliberately not the cancel token — so a timeout exits with ExitReason::Timeout and is never conflated with Interrupted. A mid-turn fire drops the partial turn whole.

per_step_timeout Idle / progress watchdog

Reset on every stream item, so a steadily progressing long turn is never tripped. Expiry becomes a synthetic retryable LlmError::Provider that flows into the existing mid-stream retry path; it is never promoted to a whole-run timeout.

per_tool_timeout Per-call ceiling

Wraps each tool inside the held concurrency permit. Expiry records a model-facing ToolOutcome::Aborted and the loop continues.

Hierarchical cancellation

Cancelling a run tears down everything beneath it

Tokens are derived, not shared, so cancellation is scoped exactly as deep as you cancel.

Run token the whole run, one session-visible handle
Per-turn child token derived per model turn
Per-call child token handed to each tool invocation
Panic containment

A panic never escapes the loop

Embedded in a host application, the loop runs untrusted-ish code on three seams. None of them can unwind into your process.

Hooks Each hook is wrapped in AssertUnwindSafe(..).catch_unwind() individually and a panic is converted into a fatal HookError.
Event sinks Each sink is caught separately, so a panicking subscriber can neither abort the fan-out to its peers nor unwind the loop.
Tools Outcomes travel in a uniform envelope that never errors past the boundary — a tool failure is data, not a stack unwind.
Get started

One crate, one builder, no ceremony

Add the crate, hand the builder a provider, and send a message. Everything else has a default, and every default is inert.

Install
cargo add runably-sdk Coming soon!

The builder defaults everything

build() is infallible — there is no configuration error to handle, because there is no required configuration.

Storage an in-memory backend
Resolver NullResolver
Clock SystemClock
Tools none, until you opt in with .with_toolsets(..)

Session and workspace CRUD works with no LLM configured at all, which is usually how you want to write the first half of your tests.

main.rs
use runably_sdk::{RunablySDK, AgentId, PermissionMode, SendSegment};

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let sdk = RunablySDK::builder()
        .with_provider(provider)
        .with_toolsets(default_tools())
        .build();

    let session = sdk
        .create_session(workspace, "demo".into(), PermissionMode::Default)
        .await?;

    let run = sdk
        .send(
            &session,
            sdk.new_conversation_id(),
            AgentId::from("code"),
            vec![SendSegment::Text { text: "add a test for the parser".into() }],
            model,
        )
        .await?;

    println!("{:?}", run.exit);
    Ok(())
}