Node.js & TypeScript
A complete typed Automate HTTP Server client for Node.js and TypeScript — endpoint discovery, readiness gating, runs, cancellation, and Electron main-process integration.
Node 18+ only; no dependencies (global fetch). Drop cephable.ts into a project and import it.
Types
// cephable-types.ts
export interface CephableHealth {
status: 'ok';
service: 'cephable-agent';
appVersion: string;
platform: string;
architecture: string;
osRelease: string;
cpu: string;
logicalCpuCount: number;
totalMemoryBytes: number;
workflowStatus: string;
activeRequestId: string | null;
modelName: string | null;
workerRunning: boolean;
busy: boolean;
workspace: string;
backend: { flavorId?: string; accelerator: string; cpuFallback: boolean } | null;
contextSize: number | null;
launchModelSelectionError: string | null;
automateModelSelection: { name: string; family: string | null; sizeCode: string | null } | null;
}
export interface CephableStep {
id: string;
index: number;
title: string;
status: 'pending' | 'running' | 'success' | 'failed' | 'skipped';
toolName?: string;
toolArgs?: Record<string, unknown>;
thinking?: string;
resultSummary?: string;
producedFilePath?: string;
producedIsDirectory?: boolean;
producedContent?: string;
producedContentId?: string;
consumedContentIds?: string[];
}
export interface CephableUsage {
inputTokens: number;
outputTokens: number;
generationMs: number;
ttftMs: number;
tps: number;
}
/** A tool the agent called that your process must execute (status `awaiting_tool_results`). */
export interface CephableToolCall {
id: string;
name: string;
arguments: Record<string, unknown>;
}
/** One executed tool result. Send `error` for a failure the agent should adapt to. */
export interface CephableToolResult {
id: string;
result?: unknown;
error?: string;
}
/** An MCP server declared on the request rather than configured in the app. */
export interface CephableInlineMcpServer {
name: string;
description: string;
transport: 'stdio' | 'http' | 'sse';
url?: string;
headers?: Record<string, string>;
command?: string;
args?: string[];
env?: Record<string, string>;
requireApproval?: boolean;
disabledTools?: string[];
}
/** A tool you declare by JSON Schema and execute yourself. */
export interface CephableClientTool {
name: string;
description: string;
parameters?: Record<string, unknown>;
}
export interface CephableRunResult {
schemaVersion: 1;
requestId: string;
taskId?: string;
status: 'completed' | 'failed' | 'canceled' | 'terminated' | 'awaiting_tool_results';
answer: string;
finalAnswer?: string;
errorCode?: string;
startedAt: string;
completedAt: string;
durationMs: number;
model: string;
appVersion: string;
backend: CephableHealth['backend'];
steps?: CephableStep[];
trace?: unknown[];
usage: CephableUsage | null;
events?: unknown[];
/** Present only when `status` is `awaiting_tool_results`. */
toolCalls?: CephableToolCall[];
/** Present only when `status` is `awaiting_tool_results`. */
resumeToken?: string;
}
export interface CephableRunRequest {
prompt: string;
taskId?: string;
timeoutMs?: number;
thinkingLevel?: 'low' | 'medium' | 'high' | 'max';
additionalWorkflowPrompt?: string;
answerContract?: string;
include?: { steps?: boolean; trace?: boolean; events?: boolean };
restrictToWorkspace?: boolean;
continuation?: boolean;
selectedSkillIds?: string[];
selectedMcpServerIds?: string[];
/** Inline MCP servers for this run only — your own tools, no user configuration needed. */
mcpServers?: CephableInlineMcpServer[];
/** Tools you execute yourself. The run parks when the agent calls one. */
clientTools?: CephableClientTool[];
hitlAnswers?: Record<string, string | string[]>;
allowDestructiveTools?: boolean;
}
export interface CephableModel {
name: string;
family: string | null;
sizeCode: string | null;
parameterCountBillions: number | null;
fileSizeMb: number | null;
supportsTools: boolean;
availableForDevice: boolean;
downloaded: boolean;
selected: boolean;
}
/** Thrown when the request never produced a run: 400, 401, 404, 409. */
export class CephableRequestError extends Error {
constructor(
message: string,
readonly status: number,
readonly type?: string
) {
super(message);
this.name = 'CephableRequestError';
}
get isBusy(): boolean {
return this.status === 409;
}
get isUnauthorized(): boolean {
return this.status === 401;
}
}
/** Thrown when a run happened and did not complete. Carries the full record. */
export class CephableRunError extends Error {
constructor(readonly result: CephableRunResult) {
super(`Automate run ${result.status}${result.errorCode ? ` (${result.errorCode})` : ''}`);
this.name = 'CephableRunError';
}
}
The client
// cephable.ts
import {
CephableHealth,
CephableModel,
CephableRequestError,
CephableRunError,
CephableRunRequest,
CephableRunResult,
} from './cephable-types';
const DEFAULT_PORT = 4317;
const PORT_ATTEMPTS = 12;
const READY_STATUSES = new Set(['idle', 'terminated']);
export interface CephableClientOptions {
/** Base URL. Omit to discover it across the port window Cephable uses. */
endpoint?: string;
token?: string;
}
export class CephableClient {
private endpoint: string | null;
private readonly token: string;
constructor(options: CephableClientOptions = {}) {
const token = options.token ?? process.env.CEPHABLE_AUTOMATE_KEY;
if (!token) throw new Error('Set CEPHABLE_AUTOMATE_KEY or pass a token');
this.token = token;
this.endpoint = options.endpoint ?? null;
}
/**
* Resolve and cache the endpoint. Cephable moves to the next free port when its preferred one is
* taken, so a saved address can silently be the wrong one — sweep the same window the app uses.
*/
async resolveEndpoint(): Promise<string> {
if (this.endpoint) return this.endpoint;
for (let offset = 0; offset < PORT_ATTEMPTS; offset += 1) {
const candidate = `http://127.0.0.1:${DEFAULT_PORT + offset}`;
try {
const response = await fetch(`${candidate}/health`, {
headers: { authorization: `Bearer ${this.token}` },
});
// A 401 still proves a server is listening here — surface the key problem, not a sweep failure.
if (response.status === 401) {
throw new CephableRequestError(
`Cephable is listening on ${candidate} but rejected the access key`,
401,
'authentication_error'
);
}
if (!response.ok) continue;
const body = (await response.json()) as { service?: string };
if (body.service === 'cephable-agent') {
this.endpoint = candidate;
return candidate;
}
} catch (error) {
if (error instanceof CephableRequestError) throw error;
// Nothing listening here; keep sweeping.
}
}
throw new Error(
`No Cephable Automate server answered on 127.0.0.1:${DEFAULT_PORT}-${DEFAULT_PORT + PORT_ATTEMPTS - 1}. ` +
'Open Cephable and enable Extensions > Cephable features > Build & Extend > Automate HTTP Server.'
);
}
private async request<T>(route: string, init: RequestInit = {}, timeoutMs?: number): Promise<T> {
const endpoint = await this.resolveEndpoint();
const controller = timeoutMs ? AbortSignal.timeout(timeoutMs) : undefined;
const response = await fetch(`${endpoint}${route}`, {
...init,
signal: controller,
headers: {
authorization: `Bearer ${this.token}`,
'content-type': 'application/json',
...(init.headers ?? {}),
},
});
const body: unknown = await response.json().catch(() => ({}));
const record = body as { schemaVersion?: number; error?: { message?: string; type?: string } };
// A run that failed comes back as HTTP 500 with a COMPLETE record. Only an envelope without
// schemaVersion means no run happened.
if (!response.ok && record.schemaVersion !== 1) {
throw new CephableRequestError(
record.error?.message ?? `${route} returned HTTP ${response.status}`,
response.status,
record.error?.type
);
}
return body as T;
}
health(): Promise<CephableHealth> {
return this.request<CephableHealth>('/health');
}
async models(): Promise<CephableModel[]> {
const body = await this.request<{ data: CephableModel[] }>('/v1/automate/models');
return body.data;
}
/** Pin a model for this app session. Pass {} to restore the app's own selection. */
selectModel(selector: { modelName?: string; family?: string; sizeCode?: string }): Promise<{
selected: CephableModel | null;
models: CephableModel[];
}> {
return this.request('/v1/automate/models/select', {
method: 'POST',
body: JSON.stringify(selector),
});
}
/** Always safe to call, even when nothing is running. */
cancel(force = false): Promise<{
stopped: boolean;
mode: 'cancel' | 'terminate' | 'none';
requestId: string | null;
workflowStatus: string;
}> {
return this.request('/v1/automate/cancel', { method: 'POST', body: JSON.stringify({ force }) });
}
/** Block until the assistant is free. The user's own panel runs hold the slot too. */
async waitUntilReady(timeoutMs = 300_000): Promise<CephableHealth> {
const deadline = Date.now() + timeoutMs;
let last: CephableHealth | undefined;
while (Date.now() < deadline) {
last = await this.health();
if (!last.busy && READY_STATUSES.has(last.workflowStatus)) return last;
await new Promise((resolve) => setTimeout(resolve, 1000));
}
throw new Error(`Cephable stayed busy for ${timeoutMs}ms (last status: ${last?.workflowStatus})`);
}
/** Run a task. Returns the record for any outcome; throws only if no run happened. */
async run(request: CephableRunRequest): Promise<CephableRunResult> {
// Keep the client deadline above the server's, so the server's own timeout wins and we do not
// abandon a run that is still executing inside Cephable.
const serverTimeout = request.timeoutMs ?? 900_000;
return this.request<CephableRunResult>(
'/v1/runs',
{ method: 'POST', body: JSON.stringify(request) },
serverTimeout + 30_000
);
}
/** run(), but a non-completed outcome throws CephableRunError. */
async runOrThrow(request: CephableRunRequest): Promise<CephableRunResult> {
const result = await this.run(request);
if (result.status !== 'completed') throw new CephableRunError(result);
return result;
}
/** Post results for a parked run and get its next outcome. */
resumeWithToolResults(resumeToken: string, results: CephableToolResult[]): Promise<CephableRunResult> {
return this.request<CephableRunResult>(
`/v1/runs/${encodeURIComponent(resumeToken)}/tool-results`,
{ method: 'POST', body: JSON.stringify({ results }) },
930_000
);
}
/**
* Run a task with tools this process executes, driving the park/resume loop to completion.
*
* A parked run holds the app's single inference slot and gets two minutes per round, so a handler
* that throws is reported as a tool error rather than left to time out — the agent adapts or explains,
* which is nearly always better than a dead run.
*/
async runWithTools(
request: CephableRunRequest,
handlers: Record<string, (args: any) => Promise<unknown> | unknown>
): Promise<CephableRunResult> {
let result = await this.run({
...request,
clientTools:
request.clientTools ??
Object.keys(handlers).map((name) => ({ name, description: `The ${name} tool` })),
});
while (result.status === 'awaiting_tool_results') {
const results: CephableToolResult[] = [];
for (const call of result.toolCalls ?? []) {
const handler = handlers[call.name];
if (!handler) {
results.push({ id: call.id, error: `No handler is registered for ${call.name}` });
continue;
}
try {
results.push({ id: call.id, result: await handler(call.arguments) });
} catch (error) {
results.push({ id: call.id, error: error instanceof Error ? error.message : String(error) });
}
}
result = await this.resumeWithToolResults(result.resumeToken!, results);
}
return result;
}
}
Using it
// example.ts
import { CephableClient } from './cephable';
import { CephableRequestError, CephableRunError } from './cephable-types';
const cephable = new CephableClient();
async function main() {
const health = await cephable.health();
console.log(`Cephable ${health.appVersion} · ${health.modelName} · ${health.backend?.accelerator}`);
await cephable.waitUntilReady();
const result = await cephable.runOrThrow({
taskId: 'demo-1',
prompt: 'Count the PDF files in my Documents folder.',
answerContract: 'End with FINAL ANSWER: <integer>',
include: { trace: false, events: false },
timeoutMs: 300_000,
});
console.log('answer:', result.finalAnswer ?? result.answer);
for (const step of result.steps ?? []) {
console.log(` ${step.index}. ${step.title} [${step.toolName}] → ${step.status}`);
}
console.log(`${result.durationMs}ms · ${result.usage?.outputTokens ?? 0} output tokens`);
}
main().catch(async (error) => {
if (error instanceof CephableRunError) {
console.error('The run did not finish:', error.result.status, error.result.errorCode);
const failed = (error.result.steps ?? []).filter((step) => step.status === 'failed');
failed.forEach((step) => console.error(` ${step.toolName}: ${step.resultSummary}`));
} else if (error instanceof CephableRequestError && error.isBusy) {
console.error('Cephable is busy with another run.');
} else if (error instanceof CephableRequestError && error.isUnauthorized) {
console.error('The access key was rejected. Copy it again from Extensions.');
} else {
console.error(error);
// Do not leave an abandoned run holding the single inference slot.
await new CephableClient().cancel(true).catch(() => {});
}
process.exitCode = 1;
});
Structured output with a contract
The most reliable way to get machine-readable results out of a local model: route the value through the FINAL ANSWER: marker and parse defensively.
interface FolderReport {
fileCount: number;
largestFile: string;
}
async function folderReport(folder: string): Promise<FolderReport> {
const result = await cephable.runOrThrow({
prompt: `Inspect ${folder}. Count the files and identify the largest one.`,
answerContract:
'End your reply with one line: FINAL ANSWER: {"fileCount": <integer>, "largestFile": "<file name>"}',
include: { trace: false, events: false },
});
const raw = (result.finalAnswer ?? result.answer).trim();
// A small local model will occasionally wrap the value in a code fence.
const json = raw.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '');
try {
return JSON.parse(json) as FolderReport;
} catch {
throw new Error(`The assistant did not honor the contract. Answer was: ${raw}`);
}
}
Reading produced files instead of prose
When the task's real output is a file, take the resolved path off the step rather than parsing the answer:
const result = await cephable.runOrThrow({
prompt: 'Read every .csv in the workspace and write results.md summarizing the totals.',
restrictToWorkspace: true,
include: { trace: false, events: false },
});
const written = (result.steps ?? [])
.filter((step) => step.status === 'success' && step.producedFilePath)
.map((step) => step.producedFilePath!);
console.log('Files produced:', written);
// producedFilePath is the resolved absolute path — always openable, unlike the path in toolArgs.
Giving the agent your own tools
runWithTools drives the whole park/resume loop, so your integration reads like an ordinary call:
const result = await cephable.runWithTools(
{
prompt: 'Look up order 4471 and draft the customer a reply about the delay.',
clientTools: [
{
name: 'lookup_order',
description: 'Fetch an order by its id. Returns status, items and ship date.',
parameters: {
type: 'object',
properties: { id: { type: 'string', description: 'The order id' } },
required: ['id'],
},
},
],
include: { trace: false, events: false },
},
{
lookup_order: async ({ id }: { id: string }) => {
const order = await db.orders.findById(id);
if (!order) throw new Error(`No order ${id}`); // reported to the agent as a tool error
return { status: order.status, items: order.items, shippedAt: order.shippedAt };
},
}
);
console.log(result.status, result.answer);
The agent chains your tools with its own, so draft_email can act on what lookup_order returned.
To point it at an MCP server you already host instead, the run stays a single call:
const result = await cephable.runOrThrow({
prompt: 'Look up order 4471 and draft the customer a reply about the delay.',
mcpServers: [
{
name: 'my-app',
description: 'Order lookup and customer records for the ACME store',
transport: 'http',
url: 'http://127.0.0.1:9123/mcp',
headers: { Authorization: `Bearer ${process.env.INTERNAL_TOKEN}` },
},
],
include: { trace: false, events: false },
});
See Custom tools for the trade-offs between the two, and for the LangChain / OpenAI-SDK route.
Electron main process
The server lives on loopback with no CORS, so a renderer cannot call it. Put the client in the main process and expose it over IPC.
// main.ts
import { ipcMain } from 'electron';
import { CephableClient } from './cephable';
const cephable = new CephableClient();
ipcMain.handle('cephable:run', async (_event, prompt: string) => {
await cephable.waitUntilReady(60_000);
const result = await cephable.run({
prompt,
include: { trace: false, events: false },
restrictToWorkspace: true,
});
// Return only what the renderer needs; steps carry tool args and full produced content.
return { status: result.status, answer: result.answer, durationMs: result.durationMs };
});
ipcMain.handle('cephable:cancel', () => cephable.cancel(false));
// preload.ts
import { contextBridge, ipcRenderer } from 'electron';
contextBridge.exposeInMainWorld('cephable', {
run: (prompt: string) => ipcRenderer.invoke('cephable:run', prompt),
cancel: () => ipcRenderer.invoke('cephable:cancel'),
});
Serializing your own callers
Only one run happens at a time, so an app with several features that can each trigger a run needs its own queue. Otherwise the second feature gets a 409.
class RunQueue {
private tail: Promise<unknown> = Promise.resolve();
enqueue<T>(work: () => Promise<T>): Promise<T> {
const result = this.tail.then(work, work);
// Keep the chain alive regardless of individual failures.
this.tail = result.catch(() => {});
return result;
}
}
const queue = new RunQueue();
const [a, b] = await Promise.all([
queue.enqueue(() => cephable.run({ prompt: 'Summarize note A' })),
queue.enqueue(() => cephable.run({ prompt: 'Summarize note B' })),
]);
Note that this only serializes your calls. The user can still start a run from the panel, so keep waitUntilReady and the 409 path.