Session
All options for Session.open, Session.run, ask, exec and close, including prompts and structured results.
A session combines an agent's conversation state with its execution environment.
A workspace supplies files; multiple sessions can share those files without sharing
conversation history. Import Session and SessionError from @laufwerk/sdk.
Session.open(options)
Creates a handle for a continuous session. This call itself is lazy: the underlying
Harness session is created or resumed by ask or exec.
| Property | Type | Required | Meaning |
|---|---|---|---|
key | string | Yes | Stable session key within the run; use a nonempty distinct key |
agent | HarnessAgent | Yes | Configured agent |
workspace | WorkspaceReference | With access | Reference returned by Workspace.open |
access | "read-only" | "read-write" | With workspace | Access for every turn and command in this session |
Supply both workspace and access, or neither. Returns an Effect containing
SessionHandle<Agent> without a workspace, or WorkspaceSessionHandle<Agent>
with one. Both expose key, ask and close; only the latter exposes exec.
Model, tools, instructions, environment and timeouts are not Session.open options.
session.ask(options)
Runs the next model turn in the same conversation, resuming the prior Harness state.
| Property | Type | Required | Meaning |
|---|---|---|---|
key | string | Yes | Stable, nonempty turn key within this session |
prompt | string | UserModelMessage | Yes | Text or a user message; see prompt types below |
output | Schema.Schema<A, I, never> | No | Effect Schema for JSON output; returns decoded A |
Returns an Effect of string, or A with output. Failures use SessionError.
Concurrent calls on the same continuous session are serialized. A new key means
a new turn; repeating a completed key reuses its recorded result.
Prompt types
HarnessAgentPrompt (also HarnessV1Prompt) is a string or AI SDK
UserModelMessage. The object form has required role: "user" and
content: string | Array<TextPart | ImagePart | FilePart>, plus optional
providerOptions (provider-name keys mapped to provider-specific options).
| Content part | Properties |
|---|---|
| Text | type: "text", text: string, optional providerOptions |
| Image | type: "image", image: DataContent | URL, optional mediaType: string, optional providerOptions |
| File | type: "file", data: DataContent | URL, mediaType: string, optional filename: string, optional providerOptions |
DataContent accepts a base64 string, Uint8Array, ArrayBuffer or Buffer.
The table describes the upstream message type. Both the pinned Codex and Claude
Code adapters accept text only: image and file parts throw
HarnessCapabilityUnsupportedError, surfaced through SessionError. They are
not supported attachment inputs in this release. Text parts are joined with blank
lines; providerOptions on these messages are not forwarded by their text extractors.
You cannot pass a whole chat-message array or a system message as prompt.
Put persistent instructions on the agent.
import type { HarnessAgentPrompt } from "@laufwerk/sdk/harness";
export const prompt = {
role: "user",
content: [
{ type: "text", text: "Review the proposed change." },
{ type: "text", text: "Focus on incorrect behavior and missing tests." },
],
} satisfies HarnessAgentPrompt;Structured output
Laufwerk converts output to JSON Schema prompt guidance, then parses JSON
candidates from the response and validates them with the Effect Schema. The
schema must be describable as JSON Schema and require no additional services.
The return is the decoded type A, which can differ from encoded type I.
This is prompt guidance plus validation, not constrained generation. Undescribable schemas fail before inference; missing/invalid JSON or schema mismatches fail after the turn. Laufwerk does not automatically repeat that turn to repair JSON. See Types and schemas for optional fields, literals and the difference between encoded and decoded values.
session.exec(options)
Runs an exact shell command through this session's execution environment with the
workspace path as its working directory. It does not call the model or append
the command output to the conversation. Pass the output to a later ask if needed.
| Property | Type | Required | Meaning |
|---|---|---|---|
key | string | Yes | Nonempty command identity within this session |
command | string | Yes | Nonempty shell command |
Returns an Effect of SessionExecResult:
| Result property | Type | Meaning |
|---|---|---|
exitCode | number | Process exit status; check it explicitly |
stdout | string | Captured standard output |
stderr | string | Captured standard error |
A nonzero exit code is a returned command result, not automatically a failed
Effect. Setup, transport, execution or detach failures can produce SessionError.
There are no cwd, env, timeout, shell or abortSignal options here. Separate
commands do not promise a persistent shell: use cd subdir && command within one
command, or an executable script, rather than relying on an earlier shell's cd.
Completed results are recorded. An interruption between an external side effect and its recording can leave an uncertain operation requiring recovery; changing the key to rerun it is not proof the first command did nothing.
session.close()
Takes no arguments. Returns an Effect of void or fails with SessionError.
Closes the continuous session and destroys its Harness execution resources. It
does not write the workspace back. Closing an unused handle needs no environment
allocation; closing an already closed session is harmless.
Close explicitly after the intended work, including handled failure paths. Do not put close in a generic scope finalizer: workflow suspension can run finalizers even though the conversation must resume later. A closed session cannot accept new work under the same key.
Cleanup can also fail. In particular, a failed first turn may leave no saved
resume state for close to use; a still-active turn cannot be closed. Preserve
the original failure when reporting cleanup failure, and inspect the run's
resources before assuming they were released.
Session.run(options)
Runs a single turn and manages its session lifecycle. Returns text or a decoded structured result, rather than a reusable handle.
| Property | Type | Required |
|---|---|---|
key | string | Yes |
agent | HarnessAgent | Yes |
prompt | string | UserModelMessage | Yes |
workspace | WorkspaceReference | With access |
access | "read-only" | "read-write" | With workspace |
output | Schema.Schema<A, I, never> | No |
The prompt, access and output rules are the same as above. Use open when a later
question needs the previous conversation or a command must use that session's
environment. Use distinct keys for distinct single-turn tasks.
Example: implement, test, return a report
Save this complete workflow as laufwerk/workflows/implement/workflow.ts in an
initialized consumer. It assumes a Bun project with a committed bun.lock, a
working Docker installation and configured Codex subscription credentials. The
dependency installation is explicit because copied workspaces omit node_modules.
Its file changes stay in the working copy; this example does not write them back.
import { Effect, Schema } from "effect";
import { Workflow } from "@effect/workflow";
import { Session, Workspace } from "@laufwerk/sdk";
import { createDockerExecution } from "@laufwerk/execution";
import { createCodex } from "@laufwerk/sdk/harness/codex";
const execution = createDockerExecution({ credentials: "codex-subscription" });
const agent = execution.agent({
harness: createCodex(),
instructions: "Make small changes and verify them.",
});
const Report = Schema.Struct({ summary: Schema.String });
export const workflow = Workflow.make({
name: "implement",
payload: { requestId: Schema.String, request: Schema.String },
success: Report,
error: Schema.String,
idempotencyKey: input => input.requestId,
});
export const layer = workflow.toLayer(input => Effect.gen(function* () {
const workspace = yield* Workspace.open({ source: ".", execution });
const session = yield* Session.open({
key: "implementer", agent, workspace, access: "read-write",
});
const result = yield* Effect.gen(function* () {
const install = yield* session.exec({ key: "install", command: "bun install --frozen-lockfile" });
if (install.exitCode !== 0) return yield* Effect.fail(`Dependency installation failed:\n${install.stdout}\n${install.stderr}`);
yield* session.ask({ key: "implement", prompt: input.request });
const tests = yield* session.exec({ key: "test", command: "bun test" });
if (tests.exitCode !== 0) return yield* Effect.fail(`Tests failed:\n${tests.stdout}\n${tests.stderr}`);
return yield* session.ask({
key: "report",
prompt: `Tests passed. Summarize the changes. Test output:\n${tests.stdout}`,
output: Report,
});
}).pipe(Effect.either);
const closed = yield* session.close().pipe(Effect.either);
if (result._tag === "Left") {
const cleanup = closed._tag === "Left" ? `\nCleanup also failed: ${closed.left.message}` : "";
return yield* Effect.fail(`${String(result.left)}${cleanup}`);
}
if (closed._tag === "Left") return yield* Effect.fail(closed.left.message);
return result.right;
}).pipe(Effect.mapError(String)));bunx laufwerk@0.0.1-alpha.13 run implement --input '{"requestId":"fix-001","request":"Fix the failing tests and explain the change."}'This handles typed failures and closes at the explicit boundary. Defects and process crashes still require the runtime's inspection/recovery path.
Shared workspace rules
Sessions share files, not conversation or all environment state. Read-only operations may overlap; read-write turns and commands hold exclusive workspace access, blocking readers as well as writers. The lock is for the operation, not the entire lifetime of an open handle. Local execution rejects enforced read-only access. See Workspace and Execution.