# 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.

Source: https://developers.cephable.com/docs/automate-http-server/samples/node

---
Node 18+ only; no dependencies (global `fetch`). Drop `cephable.ts` into a project and import it.

---

## Types

```typescript
// 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

```typescript
// 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

```typescript
// 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.

```typescript
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:

```typescript
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:

```typescript
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:

```typescript
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](/docs/automate-http-server/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.

```typescript
// 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));
```

```typescript
// 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`.

```typescript
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.
