TypeScript SDK
Drive jcode sessions from your own program. Send a prompt, stream the reply token by token, watch tool calls, and answer permission prompts, over a stable versioned protocol.
Drive jcode sessions from your own program. Send a prompt, stream the reply token by token, watch tool calls, and answer permission prompts, over a stable versioned protocol.
The SDK drives a local jcode over a Unix socket, so you need jcode installed. Install the package:
npm install @1jehuang/jcode-sdkRequires Node 20 or newer. macOS and Linux are exercised end to end in continuous integration. Windows builds and is wired up, using a named pipe in place of a Unix socket, but has no live end-to-end coverage yet, so treat it as untested rather than unsupported.
There are two different things people mean by driving jcode from their own program, and they want opposite guarantees. The SDK offers both explicitly rather than picking one and hoping.
launch() starts a private instance with its own state directory, its own sessions, and its own sockets. It cannot see or disturb the jcode the user runs in their terminal, and close() shuts it down. This is the mode for building a product on jcode.
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 upThere is nothing else to run: 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.
The instance is cleaned up for you. close() stops its daemon and removes an ephemeral home, and a process that exits without calling it, including after an uncaught exception, is still reaped. Without that, a server that restarts would accumulate a daemon and a temp directory per restart.
A fixed jcodeHome keeps transcripts on disk. listSessions() discovers persisted records even on a fresh connection, and peekSession(id) reads a stored transcript without attaching to or disturbing it.
connect() attaches to the jcode already running on the machine and shares its live sessions. This is what an editor plugin, a status dashboard, or a hotkey tool wants, because acting on the session the user is already in is the whole point.
# the user starts this once, and leaves it running
jcode api-bridgeconst client = await JcodeClient.connect({ clientName: "my-plugin/1.0" });
const sessions = await client.listSessions();The bridge starts the jcode server if one is not already up, listens on the API socket in the runtime directory, and translates onto the internal daemon socket. Pass --api-socket to listen elsewhere, and set JCODE_API_SOCKET to match in your program.
A launched instance inherits the user's provider logins by default, so an application does not have to reimplement OAuth for every provider. An instance with no credentials cannot reach a model at all, so the default is the one that works.
// start empty and supply your own credentials instead
const client = await JcodeClient.launch({ inheritLogins: false });Inheriting means the instance spends the user's provider quota. That is usually what an embedded agent should do, and it is worth being deliberate about.
Credentials that rotate are shared with the user's home rather than copied. Redeeming an OAuth refresh token invalidates the previous one, so two homes holding copies of a single token log each other out. jcode also reads logins from other CLIs' credential stores, and the SDK links only the exact recognized credential files. It never links whole config or tool directories, so unrelated transcripts and state stay private and cleanup cannot recurse into a real credential directory.
Temporary instance homes are owner-only, and recursive cleanup refuses every path except an SDK-created temp home. The launched process still runs as the current OS user and can spend inherited accounts' quota, so use inheritLogins: false when running untrusted application code.
The API socket is owner-only, matching the internal daemon socket it fronts. A process that can reach the API is therefore already running as the user, with the same access to their files that jcode itself has.
Isolation between instances is enforced rather than advisory. A launched instance resolves every path through its own state directory, so it cannot read the transcripts or session history of the jcode the user runs interactively, and session ids are validated instead of being pasted into a filesystem path.
What isolation does not give you is a sandbox for untrusted code. An instance runs with the user's own permissions and can read and write their files. To run code you do not trust, isolate the process itself with a container or a VM.
One prompt in, one finished turn out. run() collects the whole turn, so it is the right shape for scripts and job runners. Swap launch for connect to drive the user's own jcode instead of a private instance; everything after that line is identical.
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.
For a live UI, iterate the event stream instead. Events are buffered between next() calls, so slow work in the loop body 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 event union is discriminated on ev, so each case narrows to its own fields with no casts. Per-kind listeners work too: client.on("token_usage", handler).
| Event | Meaning |
|---|---|
text_delta | A chunk of the assistant's reply |
reasoning_delta, reasoning_done | Thinking output, for models that expose it |
tool_start, tool_input_delta, tool_exec, tool_done | A tool call, from named to argued to run to finished |
token_usage | Input, output, and cache-read token counts |
background_progress | Progress on a backgrounded task, for drawing a bar |
permission_request | The agent needs a decision before continuing |
message_accepted | The agent has your message, distinct from the frame being parsed |
session_status, model_info | Session state and the provider/model in use |
models, session_renamed, compacted | Replies to the catalog, rename, and compaction requests |
turn_done | The turn is over |
Sessions outlive your process, so a client can reconnect to work it started earlier, and several clients can watch the same session at once.
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);peekSession is the one to reach for when building a switcher or dashboard: attaching to a dozen sessions to show a preview would disturb every one of them.
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.clear(id); // empty the transcriptcancel stops generation now. softInterrupt waits for the next safe point, so the agent finishes the tool call it is in rather than leaving a half-applied edit.
The current TypeScript declarations expose these capabilities on JcodeClient. The signatures below are copied from the published SDK declarations, rather than inferred from the wire protocol.
runStructured validates JSON against the supplied JSON Schema with Ajv, sends bounded corrective prompts after invalid responses, and returns validated data plus an attempts audit trail. The schema is required. maxRetries defaults to 2. Exhausting the budget rejects with StructuredOutputError; invalid schemas use structured_schema_invalid.
A typical runStructured call supplies the session id, prompt, and an options object, then reads result.data and result.attempts.
getRuntimeInfo(sessionId) returns RuntimeInfo with server, protocolVersion, capabilities, a live healthy check, the session id, providers, and routes. The active provider and model are optional.
const runtime = await client.getRuntimeInfo(session.session_id);
console.log(runtime.providers, runtime.routes, runtime.healthy);setApiKey(provider, apiKey) persists a provider key in jcode's owner-only provider store and hot-reloads it. clearApiKey(provider) removes it. Prefer a private JcodeClient.launch instance for application-owned credentials, with inheritLogins false when it must not inherit the user's accounts. This is credential isolation, not a sandbox.
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");archiveSession hides a session from the default list, restoreSession makes it visible again, and setRetentionPolicy(archiveAfterDays?) configures automatic archiving of inactive sessions. Omitting archiveAfterDays disables the policy. These operations are reversible: archiving does not delete the transcript, and there is no SDK session-delete method.
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 archivingThe session-scoped helpers readFile(sessionId, path, maxBytes?), findFiles(sessionId, query, limit?), searchText(sessionId, query, options?), and fileStatus(sessionId, path) are restricted to the session's working directory. Paths outside it are rejected. Use maxBytes and limit to bound results.
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");Pass noReply to persist a user message into session context without invoking the model or emitting a turn. The promise resolves only after the daemon has persisted it. Ordinary messages still wait for message_accepted by default, or can use waitForAccept false for fire-and-forget.
await client.sendMessage(id, "Use the repository conventions for the next turn", {
noReply: true,
});globalEvents() discovers persisted and newly created sessions, opens one child connection per session, and fans their events into one bounded async iterator. It starts observing each session when that child attaches, so events emitted before discovery are not replayed. Returning from the iterator or aborting closes every child. It requires a native socket connection because a custom transport cannot be cloned safely.
const abort = new AbortController();
for await (const event of client.globalEvents({ signal: abort.signal })) {
if (event.ev === "turn_done") console.log(event.session_id);
}A client that cannot enumerate models cannot offer a picker, so the catalog is first-class. It is served from the push the daemon sends on attach, which means opening a picker costs no round trip:
const { models, current } = await client.listModels(id);
await client.setModel(id, "claude-opus-5");An unknown model, or one the provider refuses, rejects with invalid_request rather than silently leaving the session where it was. When the model changes, every client attached to that session receives a model_info event, so a UI that did not make the change still updates.
setReasoningEffort(id, effort) sets how much the model deliberates before answering. The accepted values are per-provider, typically minimal through max, so this takes a string and reports what the provider says instead of guessing at a list that would go stale.
compact(id) summarizes the transcript so far, freeing context. It is asynchronous: the daemon summarizes at the next safe point rather than interrupting a turn, so it resolving means the request was accepted, not that the transcript has already shrunk. Read the history afterwards for the result. It is refused below about 10% context usage, on the grounds that there is nothing worth compacting yet, and the rejection carries the current usage, so treat it as information for the user rather than an error to retry.
await client.renameSession(id, "nightly refactor"); // omit the title to clear it
await client.rewind(id, 4);
await client.rewindUndo(id); // rewind is reversible
await client.cancelSoftInterrupts(id); // retract what is queuedWhen the agent wants to do something that needs approval, it emits permission_request and waits. Nothing else happens on that session until you answer, so this is the hook for putting a real approval UI in front of your users:
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 for you. Think about what the agent can reach before you use it.
SDK and protocol failures reject with a HarnessError. Its stable code is the value to branch on; the message is diagnostic text and can change. Ordinary JavaScript errors, such as an OS filesystem error, can still surface from the platform.
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;
}
}| Code | Cause | Recovery |
|---|---|---|
jcode_not_found | launch() could not execute jcode | Install jcode, put it on PATH, or pass binary with an absolute path |
startup_failed | The private instance exited before opening its socket; stderr is in the message | Fix the reported configuration, credential, or binary error before retrying |
startup_timeout | The API socket was not opened within startupTimeoutMs | Increase the timeout on slow machines; otherwise inspect stderr and runtime-directory permissions |
invalid_instance_home | The instance home or a credential path is unsafe: shared with the user home, a link, file, or traversal | Choose a separate real directory; never point a private instance at the live user home |
connect_failed | The bridge is absent, dead, or listening on another socket | Run jcode api-bridge and verify socketPath or JCODE_API_SOCKET |
handshake_failed | The peer sent an invalid handshake frame | Confirm this is a harness socket and upgrade jcode and the SDK together |
unsupported_version | Client and bridge do not share a protocol major version | Upgrade the older side; unchanged retries cannot succeed |
| Code | Cause | Recovery |
|---|---|---|
disconnected | The socket closed or a write failed while work was in flight | Reconnect; retry reads, but verify mutating requests before repeating them |
timeout | No correlated reply arrived within requestTimeoutMs | Check daemon health or raise the timeout; treat a mutation's outcome as unknown |
unexpected_reply | A valid reply had the wrong event kind for the SDK method | Upgrade both sides and report their versions if it persists |
unknown_request | The bridge does not implement that request tag | Upgrade jcode or avoid the newer SDK method |
unknown_session | The session is absent, belongs to another instance, or requires attachment | Refresh listSessions(), use the right instance, and attach when required |
invalid_request | Arguments or current state violate the operation contract | Correct the constraint named in the message; do not blindly retry |
invalid_option | A client option is outside its allowed range | Correct the named option, such as discoveryIntervalMs or maxBufferedEvents |
internal | The bridge or daemon failed unexpectedly | Keep the message and logs, retry once if safe, then report reproducible failures |
| Code | Cause | Recovery |
|---|---|---|
unsupported_transport | globalEvents() cannot clone a custom transport | Use a native socket client or consume per-session events() streams |
event_buffer_overflow | The globalEvents() consumer fell behind its bounded queue | Consume faster or deliberately raise maxBufferedEvents, then recreate the iterator |
concurrent_next | Two callers invoked next() on one global event iterator | Use one consumer and fan events out inside the application |
structured_schema_invalid | The JSON Schema passed to runStructured() is invalid | Fix the schema; unchanged retries cannot succeed |
structured_output_invalid | The model exhausted the retry budget without schema-valid JSON | Inspect StructuredOutputError.validationErrors, lastText, and attempts; revise the prompt/schema |
Protocol error frames not tied to a pending request arrive on harness_error. Transport failures arrive on error and close the client. Register an error listener when using EventEmitter-style listeners because Node treats an unhandled error event as fatal.
Future server codes remain available through HarnessError.code. Keep a default branch and display the diagnostic message rather than assuming this table can never grow.
| Method | Purpose |
|---|---|
JcodeClient.launch(options) | Start a private instance and connect to it |
JcodeClient.connect(options) | Attach to the jcode already running on this machine |
listSessions({ includeArchived? }) | Discover persisted sessions, optionally including archived ones |
archiveSession(id) / restoreSession(id) | Reversibly hide or restore a session |
setRetentionPolicy(days?) | Configure automatic reversible archival |
createSession(workingDir?) | Create a session and attach to it |
attachSession(id) / detachSession(id) | Subscribe / unsubscribe to 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) | Return JSON validated against a schema with bounded retries |
events(sessionId?) | Async iterator over stream events |
globalEvents(options?) | Fan every discovered session into one bounded event iterator |
cancel(id) / softInterrupt(id, content, urgent?) | Interrupt a turn |
getHistory(id) / peekSession(id, limit?) | Read a transcript; peek works without attaching |
clear(id) / rewind(id, index) | Edit history |
respondToPermission(id, requestId, decision) | Answer a permission prompt |
listModels(id) / setModel(id, model) | List and choose the session's model |
getRuntimeInfo(id) | Server version, capabilities, active provider/model, and routes |
setApiKey(provider, key) / clearApiKey(provider) | Provision or remove an owner-only API key |
readFile, findFiles, searchText, fileStatus | Bounded read-only access under a session's working directory |
setReasoningEffort(id, effort) | Set the cost/quality dial |
compact(id) | Schedule transcript compaction to free context |
renameSession(id, title?) | Set a session title, or clear it |
rewindUndo(id) | Restore what the last rewind removed |
cancelSoftInterrupts(id) | Retract queued soft interrupts |
ping() | Liveness check |
| Variable | Effect |
|---|---|
JCODE_API_SOCKET | Override the API socket path |
JCODE_RUNTIME_DIR | Override the runtime directory |
XDG_RUNTIME_DIR | Default runtime directory on Linux |
connect() also takes socketPath, clientName, requestTimeoutMs, and a custom transport.
The SDK is generally available and follows semver against the protocol it speaks.
tsc.The harness may add event kinds within v1, so always keep a default branch when you switch on ev. Unknown frames are typed UnknownApiEvent; narrow them with isKnownEvent.
The API is a curated subset of what jcode can do internally, so the risk is that it quietly falls behind the app it mirrors. A test in the repository diffs the API against every request the terminal app makes and fails on any capability that has not been reviewed, which means the surface is measured against a real client rather than against memory. It currently reports no gaps: everything the terminal app uses is either reachable through the API or explicitly internal to how that app draws itself.
Source, issues, and the full README live on GitHub.