@ontos-ai/openwriting-sdk
v0.1.12
Published
Build and run writing agents from trusted TypeScript applications.
Downloads
1,845
Readme
OpenWriting TypeScript SDK
Build and run writing agents from trusted TypeScript applications.
@ontos-ai/openwriting-sdk lets a server, worker, CLI, or script use OpenWriting as an agent runtime. Define agents, skills, remote tools, subagents, and sandbox access in code; run one-shot tasks or persistent sessions; stream events; and use the same typed client for workspace and document operations.
Server-side only
The SDK accepts credentials and can expose local tools and files to an agent. Use it only inside trusted processes. Never import it into browser code or send an
owrt_...Runtime Client API key to a browser.
What you can build
- One-shot writing and analysis tasks.
- Persistent, multi-turn agent sessions.
- Agents with client-hosted tools and skills.
- Agents that work in an in-memory, local, hosted remote, or custom sandbox.
- Backend-for-frontend routes that proxy OpenWriting events to a browser.
- Product integrations for projects, documents, MCP servers, user skills, memory, and LLM configuration.
- Server-side PDF and DOCX export.
The authoring API is intentionally small. OpenWriting snapshots each client-defined agent into the session, then runs it through the normal OpenWriting runtime with durable messages, events, permission requests, questions, and tool results.
In this guide
- Getting started — install the SDK and complete a one-shot writing run.
- Core concepts — understand clients, immutable agents, sessions, capability connections, and sandboxes.
- Define agents — add instructions, remote tools, skills, subagents, and permissions.
- Run one-shot tasks — submit work and interpret durable results.
- Use multi-turn sessions — continue, reconnect, inspect, rewind, and delete sessions.
- Choose a sandbox — select virtual, local, hosted remote, or custom file and command access.
- Stream events — observe work directly or proxy SSE to a browser.
- Authentication and configuration — configure credentials, endpoints, and LLM providers.
- Use workspace product APIs — integrate projects, documents, product sessions, MCP, skills, and memory.
- Export documents — render server-side PDF and DOCX files.
- Errors and runtime outcomes — distinguish authoring, SDK, run, and tool failures.
- Lifecycle and shutdown — release capability connections safely.
- Replicated Runtime Clients — reuse one client per process and avoid takeover on the hot path.
Getting started
Prerequisites
Before you begin, you need:
- Node.js 24 or newer.
- A trusted TypeScript server, worker, CLI, or script runtime with
fetch. - An OpenWriting server endpoint.
- Exactly one OpenWriting credential:
- a Runtime Client API key for scripts, workers, CLIs, and embedding hosts; or
- a Product Access Token for a trusted product backend.
- An LLM provider configured for the OpenWriting user or session, or a complete provider override supplied per run by trusted Runtime Client code.
1. Install the SDK
pnpm add @ontos-ai/openwriting-sdkThe package is ESM.
2. Configure your credential
For a Runtime Client, set:
OPENWRITING_API_KEY="owrt_..."For self-hosted or development servers, also set:
OPENWRITING_BASE_URL="http://localhost:3000"If OPENWRITING_BASE_URL is omitted, the SDK uses the hosted OpenWriting API.
3. Define your first agent
Create src/writer.ts:
import {
createOpenWriting,
defineAgent,
} from "@ontos-ai/openwriting-sdk"
const writer = defineAgent({
name: "writer",
description: "Writes concise, evidence-based drafts.",
instructions: [
"You are a careful professional writer.",
"Use only information available in the request and workspace.",
"Call out missing evidence instead of inventing it.",
].join("\n"),
})
const openWriting = createOpenWriting()
try {
const result = await openWriting.run({
agent: writer,
input: "Draft a short launch announcement for our new API.",
})
if (result.status === "completed") {
console.log(result.output)
} else {
console.log(result.status, result)
}
} finally {
await openWriting.close()
}Run the file with your normal TypeScript runtime.
createOpenWriting() reads OPENWRITING_API_KEY, OPENWRITING_ACCESS_TOKEN, and OPENWRITING_BASE_URL when the corresponding options are not passed explicitly.
4. Understand the result
run() returns one of three durable outcomes:
completed— the run finished andoutputcontains the final assistant text. When the turn usedoutputSchemaand the assistant stored JSON,structuredis that value andoutputis the assistant text, orJSON.stringify(structured)when there is no text. IfoutputSchemawas requested and no structured JSON was stored, wait-mode returnsrun_blockedwithRUN_FAILEDinstead of a silentcompleted.accepted— OpenWriting accepted the run, but the caller did not wait for completion. This is returned whenwait: falseis used or when the local wait is aborted.run_blocked— the run needs a permission decision or answer, observation ended before a normal completion, or the turn requestedoutputSchemaand the model did not store structured JSON.
With wait: true, observation continues through transient queued, running, and retrying states; transient retries are not returned as run_blocked.
A caller abort stops local waiting; it does not cancel an already accepted run. An aborted wait returns accepted with reason.code === "WAIT_ABORTED".
Core concepts
SDK client
createOpenWriting(...) creates the authenticated client. It owns HTTP access and any live capability connections used by remote tools or client-hosted sandboxes.
Client construction is synchronous and performs no network I/O. The first operation that needs server state makes the first HTTP request; capability connections are opened lazily only for sessions that need client-owned tools or sandbox operations.
The client exposes:
run— create a session, submit one message, and optionally wait.sessions— create, reconnect, inspect, and remove runtime sessions.events— subscribe to live and catch-up events or create an SSE response.workspace— product-facing sessions, projects, documents, MCP, and skill files.memory— durable user-level memory files.configLLM— global and per-session LLM configuration.remoteSandbox— direct hosted temporary sandbox operations.close— release all live capability connections.
Agent definition
defineAgent(...) creates an immutable authoring value. It has no network or authentication side effects.
When a session is created, the SDK resolves instruction files and skill directories, validates the full agent tree, and sends a canonical definition to OpenWriting. The server stores that definition as the session snapshot.
Later changes to local agent code do not change an existing session. Create a new session to use a changed definition.
Runtime session
A session stores the conversation and the immutable agent snapshot. It can contain many turns and can outlive the SDK process that created it.
OpenWriting normally creates the session ID. Advanced callers may supply sessionID during creation when they need a stable application-owned identifier; the server still validates and owns the durable session record.
Closing a session handle detaches the current SDK process. It does not delete the server-side session and does not cancel an active run. Use sessions.remove(...) only when you intend to delete the session.
Capability connection
Remote tools and client-hosted sandbox operations run in your trusted process. The SDK creates and supervises their gRPC capability connection automatically.
Applications do not need to implement protobuf services, MCP transport, reconnect loops, or connection fencing. Keep the SDK client open while those capabilities may be called.
Sandbox
The active sandbox is the agent's file address space. Model-visible files use absolute sandbox paths such as /home/me/draft.md.
OpenWriting does not expose a separate client storage API or a storage:// address space. Object storage, when used, stays behind the sandbox adapter or inside server staging.
Define agents
Every client-defined agent requires:
name— a lowercase OpenWriting identifier.description— short model-visible metadata describing when the agent is useful.instructions— the agent's behavior, inline or loaded from a UTF-8 file.
Optional fields are skills, tools, subagents, and permissions.
import { defineAgent } from "@ontos-ai/openwriting-sdk"
const writer = defineAgent({
name: "proposal-writer",
description: "Drafts technical proposals from supplied evidence.",
instructions: defineAgent.instructions.fromFile(
"./agents/proposal-writer.md",
),
})Names must start with a lowercase ASCII letter and may then contain lowercase letters, numbers, _, or -. Invalid, duplicate, and reserved names fail before session creation.
Instruction files are read when the agent is serialized for a session. They must exist, contain valid UTF-8 text, and be non-empty.
Add remote tools
A remote tool is implemented in your process and called by the OpenWriting runtime. Define its model-visible contract with a closed, object-root JSON Schema.
import {
RemoteToolError,
defineTool,
} from "@ontos-ai/openwriting-sdk"
const lookupRequirement = defineTool({
name: "lookup_requirement",
description: "Looks up one proposal requirement by identifier.",
args: {
type: "object",
properties: {
id: { type: "string" },
},
required: ["id"],
additionalProperties: false,
},
async execute(
args: Readonly<{ id: string }>,
context,
) {
const requirement = await requirementStore.find(args.id, {
signal: context.abort,
})
if (!requirement) {
throw new RemoteToolError({
code: "CUSTOM_REQUIREMENT_NOT_FOUND",
message: "No matching requirement was found.",
retryable: false,
})
}
return {
output: requirement.text,
metadata: {
source: requirement.source,
},
}
},
})Attach the tool to each agent that may call it:
const writer = defineAgent({
name: "proposal-writer",
description: "Drafts technical proposals.",
instructions: "Look up requirements before drafting.",
tools: [lookupRequirement],
})Remote tool rules:
argsmust be a JSON Schema withtype: "object".- Schemas are closed with
additionalProperties: false. - Zod schemas are not currently accepted.
executereceives validated arguments and a context containingtoolCallID,sessionID,deadline, andabort.- Treat
toolCallIDas the idempotency key for external side effects. - Return
{ output, metadata? }.outputis visible to the model; metadata must be JSON-serializable. - Throw
RemoteToolErrorfor intentional, sanitized failures the model may act on. - Ordinary exceptions are sanitized and become failed tool-call outcomes; they do not automatically fail the complete agent run.
- Failed capability outcomes carry a
dispatchStatus:not_dispatchedmeans the server did not send the request to the Runtime Client,settledmeans the client returned an execution result, andindeterminatemeans the connection closed after dispatch and the side effect result is unknown. Do not automatically replay anindeterminateside-effect call; usetoolCallIDfor an explicit idempotency or reconciliation flow.
Capability binary transfers use a 4 MiB raw chunk target and a 10 MiB protobuf payload budget per gRPC frame. Base64, protobuf, JSON, and Redis relay envelopes are included in boundary checks; aggregate results that exceed the final frame budget become typed *_RESULT_SIZE_EXCEEDED failures instead of closing the stream during parsing. The SDK application keepalive sends a ping every 5 seconds with a 2-second pong deadline so it remains below the hosted 15-second ALB idle timeout.
- Tools are not inherited by subagents. Bind them explicitly where needed.
Remote tools are not MCP tools. Their definitions are snapshotted into the session, while their executable handlers remain in the connected Runtime Client.
Add skills
A custom skill is a read-only set of instructions and supporting text resources. The skill must contain a SKILL.md file with name and description frontmatter.
---
name: proposal-writing
description: Plans and drafts evidence-based technical proposals.
---
# Proposal writing
Read `references/checklist.md` before producing the final draft.Load a skill directory:
import {
defineAgent,
defineSkill,
} from "@ontos-ai/openwriting-sdk"
const proposalWriting = defineSkill.fromDirectory({
path: "./skills/proposal-writing",
})
const writer = defineAgent({
name: "proposal-writer",
description: "Drafts technical proposals.",
instructions: "Load the proposal-writing skill before drafting.",
skills: [proposalWriting],
})You can also define a small skill inline:
const houseStyle = defineSkill({
name: "house-style",
description: "Applies the organization's writing style.",
files: [
{
relativePath: "SKILL.md",
contentText: [
"---",
"name: house-style",
"description: Applies the organization's writing style.",
"---",
"Use direct language and short paragraphs.",
].join("\n"),
},
],
})Skills are attached per agent node and are not inherited by subagents. OpenWriting deduplicates identical same-name definitions and rejects conflicting ones.
Skill resources are separate from mutable workspace files. The model activates them through OpenWriting's skill tools; the SDK does not copy them into the sandbox as ordinary editable files.
Add subagents
Client-defined subagents use the same defineAgent(...) shape:
const reviewer = defineAgent({
name: "proposal-reviewer",
description: "Finds unsupported claims and missing requirements.",
instructions: "Review the draft and report only actionable findings.",
tools: [lookupRequirement],
})
const writer = defineAgent({
name: "proposal-writer",
description: "Drafts and revises technical proposals.",
instructions: "Draft, delegate a review, then revise.",
tools: [lookupRequirement],
subagents: [reviewer],
})OpenWriting also provides explicit built-in subagent instances:
import {
builtinSubagents,
defineAgent,
} from "@ontos-ai/openwriting-sdk"
const writer = defineAgent({
name: "proposal-writer",
description: "Drafts technical proposals.",
instructions: "Research before drafting.",
subagents: [
builtinSubagents.explore({
tools: [lookupRequirement],
}),
builtinSubagents.review(),
],
})Available built-in factories are bidReview, build, compaction, explore, general, plan, review, scout, summary, and title.
Subagent bindings are explicit. Parent skills, tools, permissions, and child definitions are not inherited automatically.
Control skills and tools with permissions
Agent permissions accept ordered pattern rules with allow, ask, or deny actions.
const writer = defineAgent({
name: "proposal-writer",
description: "Drafts technical proposals.",
instructions: "Use approved sources and tools.",
skills: [proposalWriting],
tools: [lookupRequirement],
permissions: {
skills: [
{ pattern: "proposal-*", action: "allow" },
{ pattern: "internal-*", action: "deny" },
],
tools: [
{ pattern: "lookup_*", action: "ask" },
{ pattern: "rich_*", action: "deny" },
],
},
})An ask decision parks the run and returns run_blocked; the SDK never auto-approves.
These rules also apply to SDK Remote Tools. Defining a tool in tools makes it available to the agent; it does not bypass the agent's ordered allow, ask, and deny rules.
Run one-shot tasks
run(...) is the shortest path from an agent definition to a result:
const result = await openWriting.run({
agent: writer,
input: "Draft the executive summary from the files in the workspace.",
snapshot: true,
})The call:
- Creates a normal OpenWriting session.
- Snapshots the complete agent definition.
- Submits the input.
- Waits unless
wait: falseis set. - Returns an
OpenWritingRunResult.
Set snapshot: false when the session does not need filesystem checkpoint, rewind, or restore tracking:
const result = await openWriting.run({
agent: writer,
input: "Summarize this text without changing files.",
snapshot: false,
})Submit without waiting:
const accepted = await openWriting.run({
agent: writer,
input: "Draft the full response.",
wait: false,
})
console.log(accepted.sessionID, accepted.eventCursor)Select an already-configured model for a single custom-agent run (including structured-output runs):
const review = await openWriting.run({
agent: writer,
input: "Review the proposal for unsupported claims.",
llm: { model: "provider/model-id" },
outputSchema: {
type: "object",
properties: { approved: { type: "boolean" } },
required: ["approved"],
additionalProperties: false,
},
})Runtime Client API keys may send either this selection-only { model } override or a complete provider configuration for this run. A complete override is useful when the Runtime Client owns the provider credentials and the OpenWriting user has no saved global configuration:
const review = await openWriting.run({
agent: writer,
input: "Review the proposal for unsupported claims.",
llm: {
model: "user-openai-compatible/user-model",
provider: {
"user-openai-compatible": {
api: "https://api.example.test/v1",
options: {
apiKey: process.env.USER_LLM_API_KEY!,
baseURL: "https://api.example.test/v1",
},
models: { "user-model": {} },
},
},
},
})Provider credentials are scoped to that run and are stored with its durable server-side run metadata so queued workers can execute it; they are not returned in redacted session/config views. Keep them in trusted Runtime Client code; never put them in browser code. Global configLLM routes remain for Product Access Token sessions.
Keep the client open if the accepted run may call remote tools or a client-hosted sandbox.
Use multi-turn sessions
Create a session once, then send as many turns as needed:
const session = await openWriting.sessions.create({
agent: writer,
})
try {
const outline = await session.send({
input: "Create an outline.",
})
if (outline.status !== "completed") {
throw new Error(`Outline did not complete: ${outline.status}`)
}
const draft = await session.send({
input: "Expand the approved outline into a full draft.",
})
if (draft.status === "completed") {
console.log(draft.output)
}
} finally {
await session.close()
}Useful send options:
wait— defaults to waiting for completion.signal— abort local submission or observation work.sandbox— override the client's default sandbox for the turn.currentPath— provide the product's current document path.storageAttachments— forward trusted product attachment descriptors.idempotencyKey— safely retry a prompt submission before acceptance.llm— select an already-configured model with{ model: "provider/model-id" }, or provide a complete one-run provider override withmodelandprovider.outputSchema— closed object-root JSON Schema for this turn's structured final result. Nested object schemas are closed.$refand$dynamicRefare rejected. The schema is capped at 32KiB. Wait-modecompletedresults includestructuredwhen the assistant stored JSON. If the schema was requested and none was stored, wait-mode returnsrun_blockedwithRUN_FAILED.
The session uses its create-time agent snapshot for every turn.
const outline = await session.send({
input: "Return a bid outline.",
outputSchema: {
type: "object",
properties: {
title: { type: "string" },
sections: { type: "array", items: { type: "string" } },
},
required: ["title", "sections"],
additionalProperties: false,
},
})
if (outline.status === "completed") {
console.log(outline.structured)
}Abort a run safely
Use the runID returned by an accepted, completed, or blocked run to stop exactly the run the caller observed:
const accepted = await openWriting.run({
agent: writer,
input: "Draft the full response.",
wait: false,
})
const abortResult = await openWriting.sessions.abort({
sessionID: accepted.sessionID,
expectedRunID: accepted.runID,
})
switch (abortResult.status) {
case "aborted":
console.log(`Aborted ${abortResult.runID}`)
break
case "run_mismatch":
console.log(`A newer run is active: ${abortResult.activeRunID}`)
break
case "already_settled":
case "not_running":
break
}expectedRunID is a compare-and-cancel guard. If a delayed stop request for run A arrives after run B becomes active, the server returns run_mismatch and leaves run B untouched. already_settled means the expected run is terminal and no other run is active; not_running means there is no current run and the expected run is not a known terminal run for that session.
This differs from the signal option on run(...) and session.send(...): aborting an AbortSignal stops the caller's local submit or wait work, while sessions.abort(...) requests durable server-side run cancellation.
List sessions and messages
const sessions = await openWriting.sessions.list()
const messages = await openWriting.sessions.getMessages({
sessionID: sessions[0].id,
limit: 50,
})Pass beforeMessageID to page backward through messages.
Look up one session and its run status
Trusted BFF hosts that already persist a session ID should use sessions.get / sessions.getStatus instead of scanning sessions.list(). Both throw on 404.
const summary = await openWriting.sessions.get({ sessionID })
const status = await openWriting.sessions.getStatus({ sessionID })
if (status.status.type === "busy") {
console.log(`Session ${summary.id} is still running.`)
}Reconnect to a session
Persist the session's sdk block alongside its ID, or read it from sessions.get({ sessionID }). Reconnecting requires the same compatible agent definition so the SDK can restore remote tool handlers and sandbox capabilities without changing the stored snapshot.
const summary = await openWriting.sessions.get({ sessionID })
if (!summary.sdk) {
throw new Error("Session does not contain an SDK definition.")
}
const session = await openWriting.sessions.connect({
sessionID,
sdk: summary.sdk,
agent: writer,
})takeover: true is an advanced recovery operation that explicitly replaces a different healthy capability owner. Normal reconnect and automatic recovery do not request takeover. See Replicated Runtime Clients before using takeover from a load-balanced backend.
Delete a session
await openWriting.sessions.remove({ sessionID })This is a hard delete. Closing a handle is the normal way to release local resources without deleting history.
Checkpoints and document rewind
Sessions with snapshot tracking can list and restore durable document checkpoints:
const checkpoints = await openWriting.sessions.listCheckpoints({
sessionID,
})
const latest = checkpoints[0]
if (latest) {
await openWriting.sessions.restoreCheckpoint({
sessionID,
checkpointID: latest.id,
})
}Message-driven rewind restores document state associated with an earlier turn:
await openWriting.sessions.revertDocuments({
sessionID,
messageID,
})
await openWriting.sessions.undoRewind({ sessionID })Checkpoint and rewind support depends on the active sandbox and server storage configuration. Hosted remote sandbox files are temporary and are not a durable checkpoint store. Create a session with snapshot: false when this behavior is unnecessary.
Handle permissions and questions
Wait-mode calls return control when a permission or question needs client input. The server can keep the original tool call suspended while the SDK returns run_blocked:
let result = await session.send({
input: "Review and revise the proposal.",
})
while (result.status === "run_blocked") {
if (result.reason.code === "PERMISSION_REQUIRED") {
await session.replyPermission({
permissionRequestID: result.reason.permissionRequestID,
decision: "once",
})
result = await session.continueRun({ blocked: result })
continue
}
if (result.reason.code === "QUESTION_REQUIRED") {
await session.replyQuestion({
questionRequestID: result.reason.questionRequestID,
decision: "answer",
answers: [["Use the conservative estimate."]],
})
result = await session.continueRun({ blocked: result })
continue
}
break
}Permission decisions are once, always, or reject. Questions can be answered or rejected.
Always call continueRun({ blocked }) after settling a parked run. Do not call send(...) to resume it; that would create a new turn.
Permission requests time out after 30 minutes. The server rejects the waiting tool call, emits permission.replied with timeout feedback, and fails the current run. After a timeout, submit a new message if the agent should retry.
For a run that can call SDK Remote Tools or a Client Sandbox, the session handle retains its capability connection while permission or question input is pending. The retention is bounded to 30 minutes. If the stream drops during that window, the SDK reconnects the same connection identity and replyPermission() or replyQuestion() waits for capability readiness before settling the blocker. Keep the session handle and parent client open until the run is continued or abandoned.
continueRun() combines live event observation with durable /permissions and /questions recovery. This allows it to discover a later blocker even when the Redis/SSE notification was lost; recovered records must match both the original run and the owning session.
Trusted automation can bypass interactive ask rules for one run:
const result = await session.send({
input: "Apply the approved edits.",
permissionMode: "allow_all",
})permissionMode: "allow_all" is per run and is not stored as a reusable grant. Explicit deny rules still win. Use it only when the trusted caller has independently authorized every action the agent may attempt during that run.
Choose a sandbox
The SDK supports four sandbox kinds.
Default sandbox
Omit sandbox or use defaultSandbox():
const openWriting = createOpenWriting()The default is an SDK-owned virtual sandbox. It is useful for isolated work that does not need access to the Runtime Client's real filesystem.
Local sandbox
Grant the agent access to an explicit host directory:
import {
createOpenWriting,
localSandbox,
} from "@ontos-ai/openwriting-sdk"
const openWriting = createOpenWriting({
sandbox: localSandbox({
cwd: "/srv/workspaces/proposal-42",
env: {
NODE_ENV: "production",
},
dangerouslyAllowFullInternetAccess: false,
}),
})cwd is required. Only pass a directory and environment variables that the agent is allowed to read or modify.
Local sandbox access is a security grant. Network access defaults to enabled unless dangerouslyAllowFullInternetAccess: false is set.
Hosted remote sandbox
Use OpenWriting-managed temporary compute:
import {
createOpenWriting,
remoteSandbox,
} from "@ontos-ai/openwriting-sdk"
const openWriting = createOpenWriting({
sandbox: remoteSandbox(),
})The server must have a remote sandbox provider and scheduler configured. The client does not receive vendor lifecycle APIs or pass vendor credentials.
Remote sandboxes are temporary compute, not a durable document home.
Custom sandbox
Adapt an application-owned provider with createSandboxFromApi(...):
import {
createOpenWriting,
createSandboxFromApi,
type SandboxApi,
} from "@ontos-ai/openwriting-sdk"
const api: SandboxApi = createMySandboxAdapter(providerInstance)
const openWriting = createOpenWriting({
sandbox: createSandboxFromApi(api, {
virtualRoot: "/home/me",
}),
})Every adapter must implement:
readFilereadFileBufferwriteFilestatreaddirexistsmkdirrm
Optional capabilities:
execenables model-visible foregroundbash.globandgrepenable native search. Implement both or neither.
The application creates, pauses, resumes, and destroys the provider sandbox. The SDK only adapts an existing instance.
See docs/examples/from-e2b.ts for a complete provider adapter.
Sandbox behavior
- File operations always target the active sandbox's
SandboxFs. - Native
bashis available only when the active sandbox exposesSandboxExec. - Custom adapters without
execdo not expose nativebash. - File addresses are absolute sandbox paths.
- Per-turn sandbox overrides are passed to
session.send(...)orcontinueRun(...). - Remote tools and sandbox execution share the SDK-managed capability connection when they run in the client process.
Use hosted temporary sandbox operations directly
openWriting.remoteSandbox is a separate control surface for an existing session. It does not change that session's active sandbox.
Run a command:
const result = await openWriting.remoteSandbox.bash({
sessionID,
command: "python analyze.py",
cwd: "/home/me",
timeout: 300,
})
console.log(result.output)
console.log(result.metadata.sandboxInfo?.sandboxGeneration)Read or write files:
const readResult = await openWriting.remoteSandbox.fs({
sessionID,
operation: "read_file",
path: "/home/me/output.txt",
})
const writeResult = await openWriting.remoteSandbox.fs({
sessionID,
operation: "write_file",
path: "/home/me/input.txt",
bytes: new TextEncoder().encode("Input data"),
})Supported filesystem operations are read_file, write_file, stat, exists, and readdir.
Watch sandboxGeneration. A changed generation means sandbox-local state may have reset.
Stream events
Subscribe to all events, one session, or a root plus its descendants:
const abortController = new AbortController()
for await (const event of openWriting.events.subscribe({
sessionID,
includeDescendants: true,
afterCursor,
signal: abortController.signal,
})) {
console.log(event.type, event.sessionID, event.rootSessionID, event.cursor)
}When both sessionID and afterCursor are supplied, the SDK catches up from durable session events and then continues with the reconnecting live stream. includeDescendants: true asks the server for the session tree under that ID so subagent events are not dropped. Sibling roots owned by the same user stay filtered out.
Treat cursors as opaque resume tokens. Store and pass them back unchanged.
Proxy events to a browser
A trusted fetch handler can return an SSE response:
export async function GET(request: Request): Promise<Response> {
const url = new URL(request.url)
const sessionID = url.searchParams.get("sessionID") ?? undefined
const afterCursor = url.searchParams.get("afterCursor") ?? undefined
return openWriting.events.createSseResponse(request, {
...(sessionID ? { sessionID } : {}),
...(afterCursor ? { afterCursor } : {}),
})
}The browser connects to your route with EventSource; it never receives the SDK credential.
If a browser reconnects with Last-Event-ID, or supplies an invalid cursor, the response emits openwriting.reload_required and ends. Reload the canonical session state before reconnecting.
Authentication and configuration
Runtime Client API key
Use apiKey for a trusted embedding host:
const openWriting = createOpenWriting({
apiKey: process.env.OPENWRITING_API_KEY,
baseURL: process.env.OPENWRITING_BASE_URL,
})The key is scoped to an OpenWriting user. The server derives identity from the credential; the SDK does not accept a caller-supplied user ID.
Product Access Token
Use accessToken in a trusted product backend:
const openWriting = createOpenWriting({
accessToken: () => productSession.getCurrentAccessToken(),
baseURL: process.env.OPENWRITING_BASE_URL,
})The provider can be synchronous or asynchronous. It is called before authenticated HTTP requests and capability reconnects; the SDK does not cache its result or automatically replay a request after 401 Unauthorized.
Credential rules
- Provide exactly one of
apiKeyoraccessToken. - If neither is passed, the SDK reads
OPENWRITING_API_KEYorOPENWRITING_ACCESS_TOKEN. - Setting both environment variables is an error.
- Explicit options take precedence over environment fallback.
- The same credential authenticates HTTP and capability connections.
Client options
createOpenWriting(...) accepts:
apiKey— Runtime Client API key.accessToken— Product Access Token or provider.baseURL— OpenWriting HTTP endpoint.capabilityURL— optional separate gRPC capability endpoint.fetch— custom fetch implementation.sandbox— default sandbox descriptor.
capabilityURL falls back to OPENWRITING_SDK_CAPABILITY_URL, then to the origin of baseURL.
Configure LLM providers
configLLM manages user-level provider configuration and session overrides.
Select a previously configured model:
await openWriting.configLLM.selectModel("provider/model-id")Inspect or clear configuration:
const config = await openWriting.configLLM.get()
await openWriting.configLLM.clear()Set a session-only model selection:
await openWriting.configLLM.setSessionOverride(sessionID, {
model: "provider/model-id",
})
const override =
await openWriting.configLLM.getSessionOverride(sessionID)
await openWriting.configLLM.clearSessionOverride(sessionID)Full provider configuration supports OpenAI-compatible endpoints, API keys, enabled-provider lists, and model capability metadata. Session and per-run overrides are persisted server-side; per-run credentials are used only by that run. Keep provider secrets in trusted server code.
Client-defined agents intentionally do not contain a model field. Model configuration is owned by the user, session, or trusted run layer rather than by the immutable agent definition.
Use workspace product APIs
openWriting.workspace groups product-facing operations. Authentication and authorization are still enforced by the server.
Projects
const project = await openWriting.workspace.projects.create(
"Launch proposal",
)
const projects = await openWriting.workspace.projects.list()
if (project.projectID) {
await openWriting.workspace.projects.delete(project.projectID)
}Project documents
const document =
await openWriting.workspace.projectDocuments.create(projectID, {
relativePath: "drafts/outline.md",
contentText: "# Outline\n",
})
const current =
await openWriting.workspace.projectDocuments.read(
projectID,
"drafts/outline.md",
)
await openWriting.workspace.projectDocuments.save(projectID, {
relativePath: current.relativePath,
contentText: `${current.contentText}\n## Scope\n`,
baseContentText: current.contentText,
baseVersion: current.version,
baseHash: current.hash,
})Document APIs also support directories, rename, delete, version history listing, and reading a historical version.
Product sessions
workspace.sessions covers product session creation and state:
creategetStateOrNullgetStatusOrNulllistEventsPagelistSideEffectslistCommandssubmitCommandresendMessageabortlistCheckpointsrestoreCheckpointrevertunrevertlistQuestionslistPermissions
Use openWriting.sessions for SDK Custom Agent runtime sessions. Its abort({ sessionID, expectedRunID }) method is race-safe and available to Runtime Client credentials. Use openWriting.workspace.sessions when implementing the product UI and its canonical state, history, commands, and rewind controls; its legacy abort(sessionID) method remains an unconditional product-session operation.
MCP servers
workspace.mcp can list, upsert, enable, disable, and delete user MCP server configurations:
await openWriting.workspace.mcp.upsert({
name: "knowledge",
config: {
type: "remote",
url: "https://mcp.example.com",
enabled: true,
},
})User skill files
workspace.skills supports listing, reading, saving, creating directories, renaming, deleting, and toggling system or user skills.
These mutable user-level skill files are different from immutable SDK skill resources submitted in an agent definition.
Read user memory
Memory is durable user-level context, separate from workspace projects and sandbox files:
const files = await openWriting.memory.list()
if (files[0]) {
const memory = await openWriting.memory.read(
files[0].relativePath,
)
console.log(memory.contentText)
}The current SDK memory surface is read-only.
Export documents
Document export is a server-side subpath:
import {
DocumentExportService,
} from "@ontos-ai/openwriting-sdk/document-export"Install the peer dependencies needed by your selected formats:
pnpm add docx jszip pdf-lib playwrightExport one document:
const file = await DocumentExportService.exportSingleDocument(
{
path: "/home/me/proposal.md",
workspacePath: "proposal.md",
contentType: "text/markdown",
contentText: "# Proposal\n\nDraft content.",
},
"pdf",
)
return DocumentExportService.createDownloadResponse(file)Export a project archive:
const archive = await DocumentExportService.exportProject({
projectName: "Launch proposal",
format: "docx",
documents,
})Supported source content types are OpenWriting rich documents, Markdown, and plain text. Output formats are pdf and docx. HTML rendering is also exported for diagnostics and custom pipelines.
@fontsource/noto-sans-sc is an optional peer dependency for embedded CJK PDF fonts.
Errors and runtime outcomes
The SDK uses three distinct error channels.
Authoring errors
OpenWritingAuthoringError is thrown while defining or serializing invalid agents, skills, tools, permissions, or sandboxes.
Examples include invalid identifiers, missing SKILL.md, conflicting same-name definitions, unsupported schemas, and invalid local sandbox options.
SDK errors
OpenWritingSDKError is thrown for client setup, authentication, transport, validation, protocol, and response failures.
import {
OpenWritingSDKError,
} from "@ontos-ai/openwriting-sdk"
try {
await openWriting.sessions.list()
} catch (error: unknown) {
if (error instanceof OpenWritingSDKError) {
console.error(error.code, error.message)
}
}Use code for program control and message for a safe summary. Low-level details may be available in cause; do not expose them directly to untrusted users.
Run and tool outcomes
Expected durable run states are returned as OpenWritingRunResult, not thrown.
Remote tool failures are tool-call outcomes inside the agent loop. Throw RemoteToolError only when you intentionally want to provide a sanitized code, message, and retryability hint to the model.
Lifecycle and shutdown
Close resources in reverse ownership order:
const openWriting = createOpenWriting()
const session = await openWriting.sessions.create({ agent: writer })
try {
await session.send({ input: "Draft the document." })
} finally {
await session.close()
await openWriting.close()
}session.close()closes that handle's capability connection.openWriting.close()closes every session handle and capability connection owned by the client.- Both are safe cleanup operations; neither deletes server-side sessions.
- A long-lived service should reuse clients deliberately and close them on eviction or process shutdown. Load-balanced hosts should follow Replicated Runtime Clients.
- A permission- or question-blocked capability session remains connected for up to 30 minutes. Closing the session or client releases it immediately.
Replicated Runtime Clients
HTTP prompt submission has no replica affinity. Capability calls (remote tools, Client Sandbox bash, and sandbox file operations) run on whichever process currently owns the session's SDK Tool Connection, usually the replica that connected first.
- Hold one
createOpenWriting(...)per process, at module scope or in your own cache. Do not construct a new client per request if you need a stable capability owner. The SDK keeps the connection identity (connectionIDand ownership token) in that client's memory. - Never set
takeover: trueon the request hot path. Takeover is for deliberate process migration or operator recovery only. A second healthy owner is rejected unless takeover is explicit. - A request that lands on replica B can still call
session.send(). Tools and the Client Sandbox still execute on the live owner, not on B. - If
execute()orlocalSandboxdepends on this process's memory or disk, sticky-route the session in your load balancer, or fail closed in your application when this process is not the owner. The SDK allows non-owner send on purpose and does not infer local-state affinity. - After a process restart the in-memory identity is gone. Wait for the previous owner's lease to expire, or take over explicitly. Do not auto-takeover only because this replica has a new id.
The Web BFF already keeps a bounded process-local client registry. External hosts should reuse the client the same way. Wire rules remain in ADR 0025.
Security checklist
- Run the SDK only in trusted server-side processes.
- Never expose Runtime Client API keys, Product Access Tokens, provider keys, or capability credentials to browser code.
- Grant
localSandbox(...)only to a dedicated directory. - Pass only environment variables the agent is allowed to use.
- Disable full internet access when it is not required.
- Validate authorization before mapping an end-user request to a session or sandbox.
- Use
toolCallIDto make remote tool side effects idempotent. - Keep remote tool errors sanitized.
- Reuse idempotency keys when retrying message submission before acceptance.
- Treat event cursors and session SDK blocks as opaque protocol data.
- Call
close()during shutdown and cache eviction.
Current boundaries
- The SDK is TypeScript and server-side only.
- Agent definitions are immutable after session creation.
- Agent definitions do not select models.
- Remote tools use closed object-root JSON Schema; Zod is not accepted.
- Remote tool result streaming and attachments are not supported.
- Remote tools use the SDK capability connection, not MCP.
- Custom sandbox provider lifecycle belongs to the embedding application.
- The SDK exposes no client storage provider or model-visible
storage://paths. - Aborting local wait does not cancel an accepted run.
- The memory surface is read-only.
- Generated runtime-bridge protocol exports are advanced integration surfaces and are not needed by normal SDK applications.
Package entry points
@ontos-ai/openwriting-sdk— client, authoring, sessions, events, workspace, memory, LLM configuration, sandboxes, and public types.@ontos-ai/openwriting-sdk/document-export— PDF, DOCX, and HTML document rendering.@ontos-ai/openwriting-sdk/testing— SDK testing helpers.
Develop the SDK locally
The OpenWriting repository uses Node.js 24 and pnpm:
pnpm install --frozen-lockfile
pnpm --filter @ontos-ai/openwriting-sdk typecheck
pnpm --filter @ontos-ai/openwriting-sdk test
pnpm --filter @ontos-ai/openwriting-sdk buildThe build generates protocol bindings, bundles the runtime, emits declarations, and copies the OpenWriting core contract types required by consumers.
For local integration, configure both endpoints when HTTP and capability traffic use different ports:
OPENWRITING_BASE_URL="http://127.0.0.1:4096"
OPENWRITING_SDK_CAPABILITY_URL="http://127.0.0.1:50051"The server must enable its capability listener for remote tools and client-hosted sandbox operations. Multi-instance deployments require the server's Redis-backed capability relay and event fanout. Hosted remote sandbox operations additionally require the server-side sandbox scheduler/provider.
For releases, follow PUBLISHING.md. Use pnpm publish; npm publish does not apply this workspace's catalog and publishConfig transformations correctly.
Next steps
- Study the SDK design to understand immutable session definitions, runtime ownership, and the rationale behind the public API.
- Choose a sandbox architecture before exposing files or commands from a production embedding host.
- Review the single file address space when adapting durable object storage or document worktrees.
- Review credential and capability ownership before deploying long-lived or replicated Runtime Clients.
- Configure built-ins and permissions to control the native tools visible to each agent.
- Adapt an application-owned remote sandbox using the complete E2B reference adapter.
- Publish a release using the package's pnpm-only release procedure.
