SDK

Build on jcode from TypeScript.

Send a prompt, stream the reply, watch tool calls, answer permission prompts.

npm install @1jehuang/jcode-sdk

Get started

Node 20 or newer. The package installs the jcode runtime for your platform, so there is nothing else to run.

Install

npm install @1jehuang/jcode-sdk

macOS and Linux run end to end in CI. Windows builds and uses a named pipe in place of a Unix socket, but has no live end-to-end coverage yet. If optional dependencies are disabled, launch() falls back to jcode on PATH.

Quick start

One prompt in, one finished turn out. run() collects the whole turn, the right shape for scripts and job runners.

import { JcodeClient } from "@1jehuang/jcode-sdk";

const client = await JcodeClient.launch({ workingDir: process.cwd() });
const session = await client.createSession(process.cwd());

const turn = await client.run(session.session_id, "What files are in src/?", {
  autoApprove: true,
  onEvent: (event) => {
    if (event.ev === "text_delta") process.stdout.write(event.text);
  },
});

console.log("\ntools:", turn.toolCalls.map((call) => call.name));
console.log("tokens:", turn.usage);
await client.close();

A session is a conversation with a working directory. The agent reads and edits files there and picks up AGENTS.md the same way the terminal app does.

Two modes

Embedding jcode and automating the user's jcode want opposite guarantees, so the SDK offers both explicitly.

launch()Embed an agent engineA private instance with its own state, sessions, and sockets. It cannot see the user's terminal jcode.
connect()Automate the user's jcodeAttach to the running jcode and share its live sessions. For editor plugins, dashboards, hotkeys.

Embed with launch()

import { JcodeClient } from "@1jehuang/jcode-sdk";

const client = await JcodeClient.launch({ workingDir: process.cwd() });
const session = await client.createSession();

const turn = await client.run(session.session_id, "summarize this repo");
console.log(turn.text);

await client.close();  // stops the instance and cleans up

launch() starts the daemon and the bridge itself. Pass jcodeHome to keep state at a fixed path instead of a temporary directory, and binary when jcode is not on PATH.

close() stops the daemon and removes an ephemeral home. A process that exits without calling it, including after an uncaught exception, is still reaped, so a restarting server never accumulates daemons.

With a fixed jcodeHome, listSessions() discovers persisted records on a fresh connection and peekSession(id) reads a transcript without attaching.

Automate with connect()

# the user starts this once, and leaves it running
jcode api-bridge
const client = await JcodeClient.connect({ clientName: "my-plugin/1.0" });
const sessions = await client.listSessions();

The bridge starts the jcode server if needed, listens on the API socket in the runtime directory, and translates onto the internal daemon socket. --api-socket listens elsewhere; set JCODE_API_SOCKET to match.

Build

Stream turns into a live UI, manage long-lived sessions, and put real approvals in front of your users.

Streaming

Iterate the event stream for a live UI. Events buffer between next() calls, so slow work in the loop never drops deltas.

const session = await client.createSession();
await client.sendMessage(session.session_id, "hello");

for await (const event of client.events(session.session_id)) {
  switch (event.ev) {
    case "text_delta":
      process.stdout.write(event.text);
      break;
    case "reasoning_delta":
      process.stderr.write(event.text);
      break;
    case "tool_start":
      console.log("\n[tool]", event.name);
      break;
    case "turn_done":
      return;
    default:
      break;  // kinds added after your version
  }
}

The union is discriminated on ev, so each case narrows with no casts. Per-kind listeners work too: client.on("token_usage", handler).

EventMeaning
text_deltaA chunk of the reply
reasoning_delta · reasoning_doneThinking output, for models that expose it
tool_start · tool_input_delta · tool_exec · tool_doneA tool call, from named to argued to run to finished
token_usageInput, output, and cache-read token counts
background_progressProgress on a backgrounded task
permission_requestThe agent needs a decision before continuing
message_acceptedThe agent has your message
session_status · model_infoSession state, and the provider and model in use
models · session_renamed · compactedReplies to catalog, rename, and compaction requests
turn_doneThe turn is over

Sessions

Sessions outlive your process. A client can reconnect to work it started earlier, and several clients can watch one session.

const sessions = await client.listSessions();
const session = await client.attachSession(sessions[0].session_id);

const history = await client.getHistory(session.session_id);

// Read another session without attaching, so previewing does not disturb it
const preview = await client.peekSession(sessions[1].session_id, 20);

Use peekSession for switchers and dashboards: attaching to a dozen sessions to preview them would disturb every one.

Interrupting

await client.cancel(id);                                 // stop the current turn
await client.softInterrupt(id, "also check the tests");  // inject at the next safe point
await client.rewind(id, 4);                              // drop history after message 4
await client.rewindUndo(id);                             // rewind is reversible
await client.clear(id);                                  // empty the transcript

softInterrupt waits for the next safe point, so the agent finishes its current tool call instead of leaving a half-applied edit. cancelSoftInterrupts(id) retracts what is queued.

Retention

await client.archiveSession(id);       // reversible, transcript remains
await client.restoreSession(id);
await client.setRetentionPolicy(30);   // archive after 30 inactive days
await client.setRetentionPolicy();     // disable automatic archiving

Archiving hides a session from the default list and never deletes the transcript. There is no SDK delete.

Models and effort

The catalog comes from the push the daemon sends on attach, so opening a picker costs no round trip.

const { models, current } = await client.listModels(id);
await client.setModel(id, "claude-opus-5");
await client.setReasoningEffort(id, "high");

An unknown or refused model rejects with invalid_request instead of silently staying put. Every attached client receives model_info on a change. Effort values are per-provider strings, typically minimal through max.

Long sessions

compact(id) schedules a summary at the next safe point. Resolving means accepted, not finished. It is refused below about 10% context usage, and the rejection carries current usage, so show it rather than retry.

await client.compact(id);
await client.renameSession(id, "nightly refactor");  // omit the title to clear it

Permissions

When the agent needs approval it emits permission_request and waits. Nothing else happens on that session until you answer.

case "permission_request":
  const ok = await askTheUser(event.tool_name, event.description);
  await client.respondToPermission(
    session.session_id,
    event.request_id,
    ok ? "allow" : "deny",
  );
  break;

Decisions are allow, allow_always, or deny. For unattended jobs, run(id, prompt, { autoApprove: true }) answers allow. Consider what the agent can reach first.

Structured output

runStructured validates JSON against your schema with Ajv, sends bounded corrective prompts on invalid replies, and returns the validated data with an audit trail.

const result = await client.runStructured(id, "List the failing tests", {
  schema: {
    type: "object",
    properties: { tests: { type: "array", items: { type: "string" } } },
    required: ["tests"],
  },
  maxRetries: 2,  // the default
});
console.log(result.data.tests, result.attempts.length);

Exhausting the budget rejects with StructuredOutputError. An invalid schema rejects with structured_schema_invalid.

More capabilities

Runtime and routes

const runtime = await client.getRuntimeInfo(session.session_id);
console.log(runtime.providers, runtime.routes, runtime.healthy);

Server version, protocol version, capabilities, a live health check, providers, and routes. The active provider and model are optional.

Working-directory files

const file = await client.readFile(id, "src/index.ts", 64_000);
const names = await client.findFiles(id, "test.ts", 50);
const matches = await client.searchText(id, "TODO", { path: "src", limit: 20 });
const status = await client.fileStatus(id, "package.json");

Read-only and restricted to the session's working directory. Paths outside it are rejected. Bound results with maxBytes and limit.

Context-only messages

await client.sendMessage(id, "Use the repository conventions for the next turn", {
  noReply: true,
});

noReply persists a message into context without a model turn, and resolves once the daemon has stored it. Normal messages wait for message_accepted unless waitForAccept is false.

Global events

const abort = new AbortController();
for await (const event of client.globalEvents({ signal: abort.signal })) {
  if (event.ev === "turn_done") console.log(event.session_id);
}

Discovers persisted and new sessions and fans their events into one bounded iterator. Events before discovery are not replayed. Requires a native socket connection.

Operate

Whose credentials an instance spends, what isolation actually covers, what leaves the machine, and how failures surface.

Credentials

A launched instance inherits the user's provider logins by default. Without credentials it cannot reach a model, so the default is the one that works. It also spends the user's quota, which is worth being deliberate about.

// start empty and supply your own credentials instead
const client = await JcodeClient.launch({ inheritLogins: false });
await client.setApiKey("openai-api", process.env.OPENAI_API_KEY!);
// Rotate or remove it later:
await client.clearApiKey("openai-api");

Rotating credentials are linked, not copied: redeeming an OAuth refresh token invalidates the previous one, so two copies would log each other out. Only exact recognized credential files are linked, never whole config directories.

Keys set with setApiKey go to jcode's owner-only provider store and hot-reload. Temporary homes are owner-only, and recursive cleanup refuses every path except an SDK-created temp home.

Security model

The API socket is owner-only, like the daemon socket it fronts. Anything that can reach it already runs as the user.

Instances are isolated by construction. A launched instance resolves every path through its own state directory and cannot read the user's interactive transcripts. Session ids are validated, never pasted into a path.

Isolation is not a sandbox. An instance runs with the user's permissions and can read and write their files. Run untrusted code in a container or VM.

Telemetry

The package sends no analytics of its own. The jcode process it controls uses jcode's standard anonymous telemetry: install, version, provider and model, session outcome, performance, token counts, and feature categories. Never prompts, replies, code, file paths, or keys.

connect() follows the running jcode's setting. launch() with a temporary home gets a fresh anonymous ID, removed by close().

const client = await JcodeClient.launch({
  env: { JCODE_NO_TELEMETRY: "1" },
});

Setting JCODE_NO_TELEMETRY=1 or DO_NOT_TRACK=1 before starting Node has the same effect. For connect(), set either on the running jcode or create ~/.jcode/no_telemetry. Requests are fire-and-forget, bounded, never queued or retried.

Schema, retention, and backend are open source in the telemetry policy.

Errors

SDK and protocol failures reject with HarnessError. Branch on its stable code; the message is diagnostic and can change.

import { HarnessError, StructuredOutputError } from "@1jehuang/jcode-sdk";

try {
  await client.run(sessionId, prompt);
} catch (error) {
  if (error instanceof StructuredOutputError) {
    console.error(error.validationErrors, error.lastText, error.attempts);
  } else if (error instanceof HarnessError) {
    switch (error.code) {
      case "unknown_session":
        // Refresh sessions and ask the user to choose another.
        break;
      case "disconnected":
      case "timeout":
        // Reconnect; retry only when repeating the operation is safe.
        break;
      default:
        console.error(error.code, error.message);
    }
  } else {
    throw error;
  }
}

Launch and connection

CodeCauseRecovery
jcode_not_foundlaunch() could not execute jcodeInstall jcode, put it on PATH, or pass an absolute binary
startup_failedThe instance exited before opening its socket; stderr is in the messageFix the reported error before retrying
startup_timeoutNo API socket within startupTimeoutMsRaise the timeout, or inspect stderr and runtime-directory permissions
invalid_instance_homeThe home or a credential path is shared, a link, a file, or a traversalUse a separate real directory, never the live user home
connect_failedThe bridge is absent, dead, or on another socketRun jcode api-bridge and check socketPath or JCODE_API_SOCKET
handshake_failedThe peer sent an invalid handshakeConfirm it is a harness socket; upgrade both sides together
unsupported_versionNo shared protocol major versionUpgrade the older side

Requests and transport

CodeCauseRecovery
disconnectedThe socket closed or a write failed mid-requestReconnect; verify mutations before repeating them
timeoutNo reply within requestTimeoutMsCheck daemon health; treat a mutation's outcome as unknown
unexpected_replyA valid reply of the wrong kindUpgrade both sides; report versions if it persists
unknown_requestThe bridge does not implement that requestUpgrade jcode or avoid the newer method
unknown_sessionAbsent, from another instance, or needs attachingRefresh listSessions() and attach when required
invalid_requestArguments or state violate the operation contractFix the named constraint; do not blindly retry
invalid_optionA client option is out of rangeCorrect the named option
internalThe bridge or daemon failed unexpectedlyKeep the logs, retry once if safe, then report it

Streaming and structured output

CodeCauseRecovery
unsupported_transportglobalEvents() cannot clone a custom transportUse a native socket or per-session events()
event_buffer_overflowThe globalEvents() consumer fell behindConsume faster or raise maxBufferedEvents, then recreate
concurrent_nextTwo callers on one global iteratorUse one consumer and fan out in your app
structured_schema_invalidThe runStructured() schema is invalidFix the schema
structured_output_invalidRetries exhausted without schema-valid JSONInspect validationErrors and attempts; revise prompt or schema

Unsolicited protocol errors arrive on harness_error. Transport failures arrive on error and close the client; register a listener, because Node treats an unhandled error event as fatal. Keep a default branch: new codes can appear.

Reference

Every method on JcodeClient, and the guarantees behind them.

API

MethodPurpose
JcodeClient.launch(options)Start a private instance and connect
JcodeClient.connect(options)Attach to the running jcode
listSessions({ includeArchived? })Discover persisted sessions
createSession(workingDir?)Create a session and attach
attachSession(id) · detachSession(id)Subscribe to or leave a session's events
sendMessage(id, content, options?)Send a turn, or persist context with noReply
run(id, content, options?)Send and collect one full turn
runStructured(id, content, options)Schema-validated JSON with bounded retries
events(sessionId?)Async iterator over stream events
globalEvents(options?)Every session in one bounded iterator
cancel(id) · softInterrupt(id, content, urgent?)Interrupt a turn
cancelSoftInterrupts(id)Retract queued soft interrupts
getHistory(id) · peekSession(id, limit?)Read a transcript; peek does not attach
clear(id) · rewind(id, index) · rewindUndo(id)Edit history
compact(id)Schedule compaction to free context
renameSession(id, title?)Set or clear a title
archiveSession(id) · restoreSession(id)Reversibly hide or restore
setRetentionPolicy(days?)Automatic reversible archival
respondToPermission(id, requestId, decision)Answer a permission prompt
listModels(id) · setModel(id, model)List and choose the model
setReasoningEffort(id, effort)Set the cost and quality dial
getRuntimeInfo(id)Version, capabilities, provider, model, routes
setApiKey(provider, key) · clearApiKey(provider)Provision or remove an owner-only key
readFile · findFiles · searchText · fileStatusBounded reads under the working directory
ping()Liveness check

Environment

VariableEffect
JCODE_API_SOCKETOverride the API socket path
JCODE_RUNTIME_DIROverride the runtime directory
XDG_RUNTIME_DIRDefault runtime directory on Linux

connect() also takes socketPath, clientName, requestTimeoutMs, and a custom transport.

Stability

Generally available, semver against the protocol it speaks.

  • Protocol v1 is stable. The handshake negotiates a major version and rejects a server that cannot speak v1.
  • New events, fields, and methods arrive in minor releases. Existing frames keep their shape.
  • Schema drift fails CI in both directions: a Rust test catches TypeScript gaps, a Node test catches tag divergence.
  • The published tarball is installed into a throwaway project and imported as ESM, CJS, and through tsc.

Keep a default branch when switching on ev. Unknown frames are typed UnknownApiEvent; narrow with isKnownEvent.

A repository test diffs the API against every request the terminal app makes and fails on any unreviewed capability. It currently reports no gaps.

curl -fsSL https://jcode.sh/install | bash