Skip to content

docore — The AI Engine

@doable/docore is the package that turns "we have an AI provider" into "we have a multi-tenant AI agent platform". It lives at packages/docore/.

This page is a tour of the public API. Internal details (event-mapper field names, persistence formats) are not stable across versions.

Concepts

  • Engine (DoCoreEngine) — top-level handle. One per process. Owns the worker pool and event bus.
  • Session — one logical conversation: (workspaceId, projectId, userId). Backed by a Copilot SDK session.
  • Worker process — an OS process that runs one or more sessions. Spawned by the isolator using the strongest available backend (nsjail / systemd / Job Object / direct).
  • Tools — JSON-schema callable functions exposed to the model. Built-in (read_file, write_file, shell, …) plus integration tools loaded dynamically.
  • Policy — allow/deny rules applied to every tool call.
  • Event bus — typed pub/sub for everything happening inside a session.

Getting an engine

import { DoCoreEngine } from "@doable/docore";

const engine = new DoCoreEngine({
  cliPath: process.env.COPILOT_CLI_PATH,
  cliUrl: process.env.COPILOT_CLI_URL,
  defaultModel: process.env.COPILOT_DEFAULT_MODEL,
  maxWorkers: 4,
  isolation: { backend: "auto" }, // 'nsjail' | 'systemd' | 'jobobject' | 'direct'
});

await engine.start();

Opening a session

const session = await engine.openSession({
  sessionId: "ws-123/proj-abc/user-42",
  cwd: "/var/doable/projects/proj-abc",
  systemPrompt: BUILD_MODE_PROMPT,
  tools: await loadTools({ workspace, project, user }),
  permissionHandler: createPolicySandbox({ policy }),
});

The session is persistent: it survives restarts because Copilot serializes its state under ~/.copilot/session-state/<sessionId>/.

Sending a message

for await (const event of session.send({ text: "Add a contact form" })) {
  switch (event.type) {
    case "assistant.message_delta":
      process.stdout.write(event.delta);
      break;
    case "tool.call":
      console.log("→", event.tool, event.args);
      break;
    case "tool.result":
      console.log("←", event.result);
      break;
    case "session.compaction_start":
      // model is summarizing context to fit
      break;
    case "session.task_complete":
      console.log("done");
      break;
  }
}

The full event union lives in packages/docore/src/events.ts — every Copilot SDK event is mapped to a DoCoreEvent so callers don't depend on the upstream wire format.

Pools & per-user accounting

In a SaaS deployment, you don't want one user to monopolize all worker processes. Use DoCorePool + DoCoreUserManager:

import { DoCorePool, DoCoreUserManager } from "@doable/docore";

const pool = new DoCorePool({ maxWorkers: 8 });
const users = new DoCoreUserManager(pool, {
  perUserLimit: 2,           // max concurrent sessions per user
  perUserMemoryMB: 300,
});

// Acquire a worker for the user; queues if at limit.
const worker = await users.acquire({ userId, sessionId });
try {
  // ...use worker.session
} finally {
  worker.release();
}

Sandboxing tool calls

import { createPolicySandbox, PolicyStore } from "@doable/docore";

const policy = new PolicyStore({
  persistence: new FilePersistence("./policies.json"),
  defaults: { ...POLICY_DEFAULTS },
});

const sandbox = createPolicySandbox({
  policy,
  audit: (entry) => recordAudit(entry),
});

const session = await engine.openSession({
  // ...
  permissionHandler: sandbox,
});

createPolicySandbox returns a function the Copilot SDK calls before every tool execution. Decisions are allow, deny, or ask (latter is forwarded to the UI as a permission prompt).

Process isolation

The ProcessIsolator picks a backend automatically:

import { ProcessIsolator, NsjailBackend, SystemdBackend, JobObjectBackend, DirectBackend } from "@doable/docore";

const isolator = new ProcessIsolator({
  backend: process.platform === "linux"
    ? new SystemdBackend({ memoryMax: "300M", cpuQuota: "50%" })
    : process.platform === "win32"
    ? new JobObjectBackend({ memoryLimit: 300 * 1024 * 1024 })
    : new DirectBackend(),
});

Most callers never touch this directly — DoCoreEngine wires it up from its own options.

Tracing

docore includes a tiny OpenTelemetry-style tracer (tracer.ts). Plug your own sink:

import { Tracer } from "@doable/docore";

const tracer = new Tracer({
  sink: (span) => myOtelExporter.export(span),
});

Public exports cheat-sheet

Export Use
DoCoreEngine, DoCoreEngineOptions Top-level
DoCorePool, DoCoreUserManager, WorkerPool Concurrency / fairness
EventBus, DoCoreEvent, DoCoreEventOf<T> Event handling
mapSdkEvent Convert raw Copilot SDK event → DoCoreEvent
createPolicySandbox, createSandboxedPermissionHandler Tool-call gatekeeper
PolicyStore, PolicyAdmin, FilePersistence, MemoryPersistence Persisted policy
POLICY_DEFAULTS, DEFAULT_SAFE_COMMANDS, DEFAULT_DANGEROUS_COMMANDS, DEFAULT_TRAVERSAL_PATTERNS, DEFAULT_URL_ALLOWLIST Out-of-the-box rules
ProcessIsolator, NsjailBackend, SystemdBackend, JobObjectBackend, DirectBackend OS isolation
Tracer, noopTracer Observability
DoCoreServer Optional standalone HTTP server mode

The full type list is in packages/docore/src/index.ts.

See also