Skip to content
Foundry
Esc
↑↓navigate↵open⌘Jpreview
On this page

API reference

Authoring factories, builders, resource operations, context properties, and triggers.

Use this page to look up authoring options. Foundation explains the relationships between definitions, instances, and contexts. Agent sessions covers advanced session options.

Durability defines the execution guarantees; Foundry primitives maps these operations to the libraries beneath them. Package APIs can expose capabilities beyond the authoring interface described here.

Code blocks on this page illustrate API forms and may refer to definitions from earlier blocks. For complete runnable modules, use the quickstart or composition examples.

Authoring factories

import {
  agent,
  artifact,
  monitor,
  sandbox,
  schedule,
  skills,
  step,
  workflow,
  workspace,
} from "@foundry.sh/marbles";
Factory Declaration Purpose
agent agent({ name?, prompt, model?, provider?, settings?, skills? }) Who does model work, with defaults.
workspace workspace({ path }) Another directory to work on. The current workspace is implicit.
sandbox sandbox({ image, mount?, target?, … }) or sandbox({ files, image?, … }) An isolated place to run commands.
artifact artifact({ name, type }) A named output; each write adds a version.
skills skills.load().add(glob).pick(...names) A skill set an agent takes.
step step(name?).describe(text).input(s).output(s).config(c).do(fn) One piece of work.
workflow workflow(name?, tree), workflow(name?, () => tree), workflow(name?).input(s).do(fn) A named tree of steps.
schedule schedule(target).at(slot) or .every(interval) Runs a step or workflow on a clock.
monitor monitor(source).request?(init).every?(interval).do(fn) Runs when files or an HTTP response change.

Steps, workflows, and triggers register as modules load. Exports are optional and useful for sharing definitions. Import singular factories to declare resources. A step uses the plural context properties agents, workspaces, sandboxes, and artifacts to operate on them.

Definitions

Resource declarations collect reusable settings. Some context methods also accept those settings inline. Returned instances expose operations such as generate, files, and write; see Using a definition.

const reviewer = agent({
  prompt: "Review without editing.",
  provider: "codex",
});
const drafter = agent({ prompt: "Draft the page.", provider: "claude-code" });
const site = workspace({ path: "../marketing-site" }); // relative to the workspace root
const box = sandbox({ image: "docker.io/oven/bun:1-slim", mount: "." });
const scratch = sandbox({ files: { "main.py": "print(1)" } }); // seeded under /workspace
const weeklyReport = artifact({ name: "Weekly report", type: "text/markdown" });
const reviewing = skills.load().add("./skills/review/*").pick("caveman");

Agent settings

prompt is required. State the agent’s task and limits, such as “Review without editing.” provider is "codex", "claude-code", or "pi"; omitted means the first installed CLI, Claude Code before Codex. model is optional: omit it for the provider’s default, or set an id the provider serves. It does not pick the provider; an id the provider does not know is passed through with a warning, and --dry-run rejects it. skills takes a skill set.

Tool grants and agent-created triggers are stored under the agent’s ID: name when given, else the top-level const it is assigned to, else agent- and a digest of its prompt, provider, and model. Two different agents with one id fail at load with already declared … with a different prompt or route; the same agent declared twice is fine.

Workspace settings

A workspace declaration selects a directory. It creates no dashboard entry or separate runtime state. A sandbox may mount the directory.

Sandbox settings

The image form mounts a workspace read/write at /workspace. mount: "." uses the step’s working directory, including its current Git worktree. A sandbox can also mount a declared workspace, such as mount: site. Other host paths fail with escapes the trusted host roots.

The files form mounts no host workspace. It copies its seed files under /workspace after boot. Host authentication and state directories are never mounted automatically. Workspace mounts require executable: true to run files; network access defaults to disabled.

target selects the mount location; workdir selects the working directory. Other container options are network, ports, labels, publicEnvironment, detached, and resources: { cpus?, memoryBytes?, pids? }. Running a sandbox requires the optional microsandbox peer. Dry execution echoes commands without starting it.

Artifact identity

A declared artifact keeps its identity across runs; each report.artifact or artifacts.write adds a version to it.

Skills

skills.load() is the global set under ~/.foundry/skills plus the workspace’s skills/ folder; skills.none() starts empty. .add(glob) appends every folder matching the glob that holds a SKILL.md, and .add(skill) appends a skill defined in code with defineSkill({ manifest, files? }), exported from @foundry.sh/marbles. .pick(...names) narrows. Each call returns a new set. A skill’s files are read as bytes: text stays text, and an agent loading a binary file is told its size rather than shown mangled bytes.

Using a definition

Inside a step, call an agent instance for a single turn:

const reply = await reviewer.generate("Review this change.");
const stream = reviewer.stream("Review the next change.");

Use .run(input) to execute a step directly. This excerpt assumes z is imported from zod:

const countLines = step("count-lines")
  .input(z.object({ text: z.string() }))
  .do(({ input }) => input.text.split("\n").length);

const lines = await countLines.run({ text: "First line\nSecond line" });
Instance Operations Context equivalent
agent generate(input, turn?), stream(input, turn?) agents.session(def), one turn
step, workflow run(input?) Direct execution within the current process; no separate dashboard run.
workspace root, files(), git workspaces.load(def)
sandbox exec, close, and the container’s commands, filesystem, processes, shell, ports, services, volumes; started by the first call sandboxes.start(def)
artifact write(files, options?), latest() artifacts.write(def, files), artifacts.latest(def)
  • Inside a step, instance methods use its working directory, output, and cancellation. Replay depends on the operation: managed artifact writes and completed one-shot agent replies can be reused; file reads and commands can execute again. Keep managed call order stable. See the replay table.
  • Anywhere else (module top level, a plain bun script) it runs on the engine marbles built for the module, or, with none, on a default one: the installed CLI harnesses, the current directory as the workspace, and the shared artifact store under ~/.foundry/artifacts. Call dispose() from @foundry.sh/marbles when a script is done with it; a Codex turn keeps a child process open until then.
  • An agent’s call is one turn on a fresh session. For a conversation, open a session with agents.session(designer). The data fields are plain properties and the methods are not, so { ...designer } is a plain definition that agents.session takes.
  • A sandbox starts at its first call. In a step it is that step’s sandbox, closed with the run and reopened on replay; elsewhere it runs until close() or dispose(). Its filesystem is box.filesystem, since a files sandbox’s files is the seed it was declared with. agents.session(def, { sandbox: box }) takes the declared sandbox and starts it the same way.
  • run runs in this process, not through the engine’s queue, so a body can await it; a nameless definition runs too. The nested run is not shown on the dashboard and cannot be paused on its own. A step that asks a person with nobody to answer rejects with asked a person; run it with marbles to answer.

The builder

A builder ends with either .do(body) or .exec(command). The forms below show the available options, not a single executable chain.

step(name?)          // a builder
  .describe(text)    // optional: shown in the catalog and the launch form
  .input(schema)     // optional: any Standard Schema library (zod, valibot, arktype)
  .output(schema)    // optional
  .config({ retry?, timeout? }) // optional: how the step runs
  .do(fn)            // returns the definition
  // or, for a step that is one command:
  .exec("bun add $package", { check?, cwd?, environment?, sandbox? })
  • A string first argument makes the step launchable by that name. A repeated name fails at load. Without a name the step takes the name of the top-level const it is assigned to when the project’s typescript resolves from the workspace root; otherwise it is internal. See names and the catalog.
  • Schemas come before .do() so the body is typed from them. .input() gives the TypeScript type, validation at launch, a contract the catalog can read, and (with Standard JSON Schema support) structured fields in the dashboard launch form. Other input schemas use a JSON field. No input schema means no launch input. .output() validates the body’s return.
  • A parent declares its children’s results in .input() like any other input. Each child’s output is checked against the parent’s key at lock.
  • do receives one object; destructure it. It may return a value or a promise. Results must be plain JSON: a step whose result holds a Date, a Map, or a class instance fails with where it stops being JSON (files[0].updatedAt is a Date). Return an ISO string for a date.
  • .config({ retry, timeout }). timeout bounds each attempt of the body ("30s", "10m", or milliseconds): when it passes the body’s signal aborts, with everything Marbles opened for it, and the attempt fails. retry is the Workflows policy, { maxAttempts, backoff?, maxElapsed?, shouldRetry? }, with backoff one of fixed, exponential, linear, or jittered. A retried attempt runs the body again from the top, fresh: agent turns, sandboxes, artifact writes, and run calls it made are not replayed. Children that finished are not run again. maxAttempts includes the first attempt. Neither retries nor a timeout are enabled by default, and a parent’s policy does not become its children’s policy. Without retry, a throw fails the step.
  • .exec(template, options?) ends a step whose work is one command. The template is split into arguments when the module loads, honouring quotes, and never read by a shell. $name fills one argument from the input, never split again, so {"package":"two words"} is one package argument; an array fills one argument per element. The program, the first word, must be written out, and each placeholder must be an input key, or the module fails to load. .exec(({ input }) => argv) computes the list instead. The step’s output is { exitCode, stdout, stderr }; a non-zero exit fails it unless { check: false }. It runs like a body’s exec.
  • bail(error), exported from @foundry.sh/marbles, is a failure as a value: return it from do to fail the step without a retry. It is not checked against the output schema.

workflow uses the same builder plus two short forms:

workflow(
  "weekly",
  publish.parallel({ findings: review({}), exitCode: test({}) })
);
workflow("nightly", () => publish({ findings: review({}, { target: "src" }) }));
workflow("ship")
  .input(z.object({ task: z.string() }))
  .do(({ input: { task } }) =>
    approve({ review: review({ branch: implement({}, { task }) }) })
  );

A workflow’s .do is setup: it receives { input, log, run: { id } } only and returns the tree. It is not durable; see composition and durability.

Locking and trees

Calling a definition creates a node in the workflow tree. This operation is called locking. It selects children and literal input without executing work.

review({}, { target: "." }); // leaf: no children, literal input
publish({ findings: review({}) }, { exitCode: 0 }); // one child, one literal
publish.series({ a: x({}), b: y({}) }); // children in key order (default)
publish.parallel({ a: x({}), b: y({}) }); // children together
  • Children first. An object of locked nodes keyed by name. A leaf passes {}. The keys name the children in the dashboard, in logs, on resume, and in the parent’s input.
  • Input second, optional. Literal values for the step’s input. The literal takes the schema’s input type, so defaulted fields are optional.
  • The children’s results and the literal input merge into the body’s input. A key in both is an error at load, as is a child key the parent’s schema does not declare.
  • The parent runs last, after its children. Siblings are independent; data flows only from a child to its parent. A step that needs another’s result takes it as a child.
  • One definition can be locked many times; each lock is its own node.
  • .parallel is the only combinator besides the default series. There is no race, branch, or loop yet. A workflow is a definition too: weekly({}) locks it. A workflow lock is only ever the root of a run, launched, scheduled, or returned by a monitor. As a child in another tree it throws a workflow can only be the root of a run.

In a parallel group the first failure fails the run; the siblings are aborted and awaited before the failure is reported. A sibling that parks on a question parks the parent with it while the others finish.

Context

Workflow setup receives only { input, log, run: { id } }. Step bodies receive the full context below. Monitor handlers extend it with change data. See Contexts for when each is used.

Property Use
input Validated literal input and child results.
exec Run a command.
agents Open an agent session.
workspaces Read files and create Git checkouts.
sandboxes Start and use an isolated environment.
artifacts Write and read versioned outputs.
ask Wait for a person.
report Post results to the Feed.
log, stream Write output.
signal Observe cancellation or pause of this step or its run.
run Identify or abort the run.

Commands

const result = await exec(["bun", "test"], { check: false });

exec(argv, { check?, cwd?, environment?, sandbox? }) returns { exitCode, stdout, stderr }. It runs an argument list in the step’s working directory, streams output, and stops with the step. A nonzero exit fails the step unless check: false. Dry execution prints the command and substitutes an empty successful result.

Agent sessions

const session = await agents.session(reviewer);
const reply = await session.generate("Review the change.");

Use { cwd: checkout.root } to select another host directory, or { session: previousRef } to continue a conversation. Agent sessions lists host and sandbox options.

A session’s turns write to the step’s stream and stop with the step. generate returns the message with text (every text part of the final turn), output, usage, and result (the AI SDK’s result, absent for Claude Code and Codex); stream returns the harness’s stream, with output and partialOutputStream beside text and message. ref is a small serializable handle (id, and the model and provider it opened with) for continuing the session later. Network-model sessions can use compaction: true to summarize older history. Native CLI sessions manage compaction themselves and reject explicit host compaction settings.

Turn options

The second argument to generate and stream takes output (an AI SDK Output), the AI SDK’s model settings (temperature, maxOutputTokens, topP, topK, seed, stopSequences, stopWhen, toolChoice, activeTools, reasoning, providerOptions, …), signal, and handlers such as onText. They override the agent’s settings. A turn whose output gets no matching value rejects with NoObjectGeneratedError after the reply is committed. See structured output.

Workspace operations

const project = await workspaces.load({ path: "../website" });
await project.git.withWorktree(
  { base: "main", branch: "task/update" },
  async (checkout) => {
    // Use checkout.root for raw filesystem calls inside this callback.
  }
);

workspaces.current selects the current project. load(definition) or load({ path }) selects another one. A handle exposes root, files, entries, read, save, createFile, refresh, summary, and git.

A handle is the Workspaces workspace, loaded at its first call: files() returns file entries (id, path, extension, kind, size, and modification time), entries() adds directories, read(file.id) returns the content, and save and createFile write through the workspace’s confinement checks. A handle from load also has its registry id and source. Renaming or removing a workspace belongs to the host. current.git throws when the root is not a Git repository.

withWorktree({ base, branch?, home? }, body) creates a checkout under the workspace’s state, runs body with the working directory narrowed to it, and removes it on return; a branch outlives the worktree. worktree(options) creates one you must remove yourself, under the same home. Naming a branch that exists fails before creating the checkout; check first with hasBranch(name), which reads the repository even under --dry-run.

Sandbox operations

const environment = await sandboxes.start(box);
const result = await environment.exec(["bun", "test"]);

start(definitionOrSpec) returns a handle; open(id) reopens one. The handle provides id, exec, commands, files, processes, shell, ports, services, volumes, and close(). Its exec returns an exit code even on failure. Use the context’s exec(argv, { sandbox: box }) for automatic checking.

A handle is the Sandbox container. In a step every command and process carries the step’s signal, and exec writes $ <command> and its output to the step’s stream. exec takes an argv array, run directly, or a string, run by the guest’s shell, and the package’s options (cwd, environment, user). Listing, removing, and restarting containers are the host’s.

Questions and approvals

const decision = await ask.approval({
  title: "Publish this revision?",
  body: findings,
});
const region = await ask.question({
  title: "Which region?",
  choices: ["us", "eu"],
});

approval({ title, body?, key? }) returns { approved, note? }. question({ title, body?, choices?, key? }) returns a string.

approval is a hard block: the run shows as blocked until someone decides. question is input: the step waits, siblings keep going. Both need a process that can show them; the dashboard parks the run, while roll and headless run cancel it at the ask. choices gives a pick list of at most nine; without it the answer is free text. Both are identified by position in the body; key is optional, for updating one entry across steps.

Artifact operations

Method Result
write(definition, files, { replace?, metadata? }) A new version of a declared artifact.
create({ name, type, entries, metadata?, freeze? }) A one-off artifact and its first version.
get(definitionOrId) The artifact record.
listContents(definitionOrId) Its versions.
getContent(versionId) One version.
readFile(versionId, path), readRoot(versionId) Stored file content or its tree.

A version is the Artifacts content record as JSON: id, artifactId, digest, state, tag, metadata, tree, and ISO timestamps. A version written in a step records the run and its session in metadata (runId, sessionId). write replaces the files unless { replace: false }; a file is text, bytes, or the package’s file input ({ bytes, mime }, a stored blobId). Reads take the artifact’s declaration or its package id, and a version’s id.

Read across runs

artifacts.latest(def) (or def.latest()) is the artifact’s current version with text(path) and bytes(path), or undefined before the first write. A declared artifact belongs to the workspace, so this is what the last run here kept. Under --dry-run it reads what real runs kept while its own writes stay in memory. See the persistent reviewer.

Reports

report.milestone({ title: "Tests passed" });
report.result({ title: "Review", body: findings });
const version = await report.artifact(
  weeklyReport,
  { "report.md": findings },
  { body: findings }
);

milestone and result accept { title, body?, media?, key? }. artifact(definition, files, entry?) accepts optional entry settings.

Entries are fire-and-forget and land on the run and the feed. media paths resolve from the workspace root and are copied into the entry; refer to them in the body as media/<file name>. report.artifact writes a version, posts a result entry titled after the artifact unless entry.title says otherwise, and returns the version. The entry references that exact version. Supply entry.body for readable Feed content; artifact files are not automatically displayed by the Feed reader.

An artifact write by itself does not post to the Feed. Feed entries persist too, using the artifact store internally. See Artifacts and the Feed for choosing between stored outputs, messages, and returned results.

Output and cancellation

log(...values) logs at info level; log.debug, .info, .warn, and .error select another level. stream.write(value) and stream.pipe(source) write to the step’s live output. Pass signal to operations you own:

await fetch(url, { signal });

Everything Marbles owns is already cancelled with the step and already writes to the step’s stream. signal and stream are for the author’s own work: a raw fetch, a child process, a line of output.

Run information

run exposes { id, path, session, signal, stream, abort(reason) }. Use run.id to associate an artifact or log entry with this execution.

run.abort(reason) fails the whole run. run.session is the run’s own session; agents opened in the run are children of it. run.stream is the whole run’s output.

Monitor observations

A monitor’s handler extends the step context with files or response, plus change; its input is empty. Narrow change.kind before accessing source-specific data. files is { added, modified, removed }, each a { path, checksum }[] with path relative to the workspace root; read one with node:fs from resolve(workspaces.current.root, path). response is a standard Response over the polled body, with the original’s status, statusText, url, and redirected. change carries the diff: the file lists, or { status, previous, current } for HTTP, where a body that is not text is { bytes, contentType }.

Triggers

schedule(weekly).at({ weekday: "fri", hour: 16, minute: 0 }); // machine-local time
schedule(review({}, { target: "packages" })).every("6h"); // "30s" | "10m" | "6h" | "1d"

monitor("docs/**/*.md").do(({ change, log }) => {
  if (change.kind === "files") log(change.modified);
});
monitor("https://status.example.com/api")
  .every("5m")
  .do(async ({ response, report }) => {
    if (!response) return;
    const { status } = await response.json();
    report.milestone({ title: `Status: ${status}` });
  });
monitor("https://tracker.example.com/latest")
  .every("10m")
  .do(async ({ response }) => {
    if (!response) return;
    return ship({}, { task: (await response.json()).title });
  });
  • schedule(target) takes a named step or workflow, bare or as a childless lock. A lock carries its input, so there is no input field. A lock with children throws wrap it in a named workflow first; a nameless target throws has no name. .at(slot) or .every(interval) is the terminal call. weekday is "sun" through "sat" or an array; omit it for every day. minute defaults to zero. An interval is a positive integer with s, m, h, or d; "0s" is refused.
  • monitor(source) takes one string. http:// or https:// is polled and fires when the response changes: each poll hashes the body, as stable JSON when it parses, as text for another textual type, as bytes otherwise, together with the status when it is not 2xx, and compares it with the last hash. A body that carries noise such as a timestamp fires every poll; read response and return early when nothing of interest changed. Anything else is a glob over the workspace root, polled; files lists what was added, modified, or removed since the last poll. .every defaults to one minute. .do(fn) is terminal.
  • .request(init) is for an HTTP source only (a glob throws at load): a RequestInit without signal, or a function returning one, sync or async, called at every poll. It is never part of the key, the stored state, list, or the feed. A 401 or 403 fails the poll and logs the status and the URL, never what was sent; any other status is a reading, so a move to 404 or 500 is a change. A network error fails the poll and keeps the last state.
  • A handler that returns a locked node starts it, attributed to the monitor: ship({}, { task }) or nightly({}). Return a childless lock of a named definition. A bare definition is ignored; undefined launches nothing. At most one node starts per tick. The launch is held as pending until a tick has started it, so a crash in between hands it out again; a target no longer registered is dropped after one failed tick; a started run that fails or is cancelled is not started again.
  • The first poll has no baseline. Every matching file is added, or the first body counts as a change, so the handler runs once on first start. A glob that matches nothing fires nothing.
  • Keys. A schedule is keyed slug(target)[-sha8(input)], a monitor slug(source)-sha6(source); an exact duplicate gets -2. list prints the keys. They name the state files and roll <key>.

Composition and durability

A step owns the input, output stream, cancellation, and managed operations for its attempt. A worktree callback changes the working directory inherited by new commands and sessions. Session options can select a different host cwd or continue a recorded conversation.

Situation Rule
Continue the same run Reuse completed step results.
Resume a parked step Replay its unfinished body from the top. Recorded managed operations are matched by call order.
Retry a failed attempt Apply that step’s policy with fresh managed operations; completed children keep their results.
Launch again Create a new run and execute fresh work.
Restart the host Adopt eligible parked runs. Interrupted queued or running work is not adopted.
Rebuild workflow setup Recreate the tree; recovered nodes retain their recorded literal input.

Keep managed call order stable. Branch on recorded input when it controls those calls; branching on live values can change their replay positions. An explicit session turn can execute again during replay. A one-shot agent call can reuse its completed reply. Put an approval at the start of its step, with non-repeatable work in a completed child or after the answer.

The CLI saves snapshots at suspension, resumption, and settlement. Newer progress may not reach disk before a crash. The durability guide describes each operation; state lists adoption conditions.

Pausing or cancelling signals owned operations. Custom work must honor signal; a step cannot settle until its attempt stops. The dashboard supports steering, pause, and resume without authored control code.

Exports

The main entry exports the factories above, the AI SDK’s Output, bail, defineSkill, and dispose, plus these types:

Group Types
Instances Agent, Runnable, WorkspaceInstance, SandboxInstance, ArtifactInstance
Work StepDefinition, WorkflowDefinition, LockedNode
Resource definitions AgentDefinition, AgentSettings, WorkspaceDefinition, SandboxDefinition, SandboxSpec, ArtifactDefinition, SkillSet
Agents Agents, Session, SessionRef, SessionReply, TurnOptions
Workspaces and sandboxes Workspaces, WorkspaceHandle, LoadedWorkspace, Sandboxes, SandboxHandle
Artifacts Artifacts, ArtifactVersion
Execution Context, Ask, Approval, Report, Stream, Run, Log, LogLevel
Triggers Schedule, ScheduleBuilder, MonitorBuilder, CalendarSlot, Weekday, Trigger, MonitorContext, MonitorHandler, Change, FileChange, HttpChange

@foundry.sh/marbles/prebuilt exports the prebuilt step factories.

Not available yet

Do not write modules that depend on these.

  • Race, branch, and loops between steps. Only series and parallel.
  • A built-in model provider. No provider means the first installed CLI.
  • Recovery of a step that was mid-body when the process stopped. A step parked on an ask or a pause does survive a quit, a crash, or a kill.
  • Scheduling a tree with children without wrapping it in workflow.
  • Remote deployment commands, a remote dashboard, or distributed workers.

See Installation for package availability.

Was this page helpful?