npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

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-sdk

The 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 and output contains the final assistant text. When the turn used outputSchema and the assistant stored JSON, structured is that value and output is the assistant text, or JSON.stringify(structured) when there is no text. If outputSchema was requested and no structured JSON was stored, wait-mode returns run_blocked with RUN_FAILED instead of a silent completed.
  • accepted — OpenWriting accepted the run, but the caller did not wait for completion. This is returned when wait: false is 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 requested outputSchema and 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:

  • args must be a JSON Schema with type: "object".
  • Schemas are closed with additionalProperties: false.
  • Zod schemas are not currently accepted.
  • execute receives validated arguments and a context containing toolCallID, sessionID, deadline, and abort.
  • Treat toolCallID as the idempotency key for external side effects.
  • Return { output, metadata? }. output is visible to the model; metadata must be JSON-serializable.
  • Throw RemoteToolError for 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_dispatched means the server did not send the request to the Runtime Client, settled means the client returned an execution result, and indeterminate means the connection closed after dispatch and the side effect result is unknown. Do not automatically replay an indeterminate side-effect call; use toolCallID for 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:

  1. Creates a normal OpenWriting session.
  2. Snapshots the complete agent definition.
  3. Submits the input.
  4. Waits unless wait: false is set.
  5. 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 with model and provider.
  • outputSchema — closed object-root JSON Schema for this turn's structured final result. Nested object schemas are closed. $ref and $dynamicRef are rejected. The schema is capped at 32KiB. Wait-mode completed results include structured when the assistant stored JSON. If the schema was requested and none was stored, wait-mode returns run_blocked with RUN_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:

  • readFile
  • readFileBuffer
  • writeFile
  • stat
  • readdir
  • exists
  • mkdir
  • rm

Optional capabilities:

  • exec enables model-visible foreground bash.
  • glob and grep enable 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 bash is available only when the active sandbox exposes SandboxExec.
  • Custom adapters without exec do not expose native bash.
  • File addresses are absolute sandbox paths.
  • Per-turn sandbox overrides are passed to session.send(...) or continueRun(...).
  • 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 apiKey or accessToken.
  • If neither is passed, the SDK reads OPENWRITING_API_KEY or OPENWRITING_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:

  • create
  • getStateOrNull
  • getStatusOrNull
  • listEventsPage
  • listSideEffects
  • listCommands
  • submitCommand
  • resendMessage
  • abort
  • listCheckpoints
  • restoreCheckpoint
  • revert
  • unrevert
  • listQuestions
  • listPermissions

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 playwright

Export 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 (connectionID and ownership token) in that client's memory.
  • Never set takeover: true on 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() or localSandbox depends 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 toolCallID to 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 build

The 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