Search

Search the documentation

Protobox
Use cases

Use Protobox from your own runtime (REST, no MCP client)

Drive one session per conversation entirely through @protoboxai/sdk over REST: list tools, execute actions, resolve approvals, and read the same audit trail an MCP client would, with no MCP handshake anywhere.

Scenario: Acme's agent runtime already has its own tool-calling loop. It does not embed an MCP client, and never will. It still wants everything a session gives an MCP-connected agent: one scoped identity per conversation, curated tools, human-in-the-loop on risky actions, and a full audit trail. Every call below is a plain HTTP request the SDK wraps; there is no initialize handshake, no MCP transport, anywhere in this page.

This is the same session primitive Per-user MCP sessions describes for MCP clients. This page is the runtime-integration half of that story.

1. One session per conversation

Mint a session bound to the end-user (or conversation) the moment it starts:

import { ProtoboxSDK } from '@protoboxai/sdk';

const protobox = new ProtoboxSDK({ apiKey: process.env.PROTOBOX_API_KEY! });

const session = await protobox.sessions.create({
  principalId: 'conversation_c_9f2a1',  // your workspace/conversation id
  actorId: 'user_123',                  // the end-user whose credentials/memory this call uses
  toolset: 'support-tools',             // the curated tool surface for this conversation
  preset: 'direct-tools',               // tools/list serves the toolset flat, not via meta-tools
});

actorId matters even when your runtime only tracks one id per conversation: it is what scopes every memory write/read and connection lookup to that specific end-user. Passing only principalId still works (the session falls back to it as the bound user), but an explicit actorId is clearer when a conversation and an end-user are different things in your system.

2. List its tools

const tools = await session.tools();
// [{ name, description, inputSchema, integrationId? }, ...], the toolset's tools, flat

Feed tools straight into your own tool-calling loop, or pass { format: 'anthropic' | 'openai' | 'vercel' | 'langchain' | 'google-genai' } to get a wired, provider-shaped tool list whose own execute already routes through this session.

3. Execute an action by name

const result = await session.execute('KB_SEARCH', {
  arguments: { query: 'refund policy' },
  client: { name: 'chanl', version: '1.4.2' },  // see "Identify your runtime" below
});

execute returns one of three shapes:

{ status: 'completed', data }
{ status: 'pending_approval', approval: { id, expiresAt } }
{ status: 'failed', error: { code, message, retryAfter? } }

execute addresses tools by their served name, the same names session.tools() returned, and does not require the target tool to belong to this session's own toolset. It resolves against the workspace's full catalog, same as actions.execute.

4. A tool behind an approve policy: pause, approve, fetch

Gate a risky tool on the toolset once:

await protobox.toolsets.setToolPolicy(toolset.id, 'ISSUE_REFUND', 'approve');

The next call to it pauses instead of running:

const paused = await session.execute('ISSUE_REFUND', {
  arguments: { ticketId: 'T-4821', amountCents: 4900 },
  client: { name: 'chanl', version: '1.4.2' },
});
// paused.status === 'pending_approval'
// paused.approval -> { id, expiresAt }

Your reviewer surface decides, over the same API. See Human-in-the-loop for building the inbox:

const approved = await protobox.approvals.approve(paused.approval.id);
// approved.status === 'approved'
// approved.executionId -> set once the replay ran

Fetch the (now-resolved) async result:

const execution = await protobox.executions.get(approved.executionId);
// execution.outcome === 'ok' | 'denied' | 'failed'

Nothing here differs from the MCP path. approvals.approve replays the exact frozen call server-side, the same as if an MCP client's tool call had paused.

5. Knowledge search and memory, for that end-user

Knowledge and memory are native tools. Reach them through the same session, so both are scoped to the session's bound end-user and show up in the same audit trail as every other call:

await session.execute('kb_search', {
  arguments: { query: 'refund policy' },
  client: { name: 'chanl', version: '1.4.2' },
});

await session.execute('memory_store', {
  arguments: { content: 'Prefers email over phone for follow-ups.' },
  client: { name: 'chanl', version: '1.4.2' },
});

const recall = await session.execute('memory_recall', {
  arguments: { query: 'contact preference' },
  client: { name: 'chanl', version: '1.4.2' },
});

sdk.knowledge.search() and sdk.memory.store()/recall() also exist as standalone convenience methods, but calling them through session.execute() is what attributes the write/read to this conversation's end-user and session. Use the standalone methods only for workspace-level calls with no end-user or conversation to bind to.

Identify your runtime

An MCP client stamps its identity once, at initialize. A REST-only runtime has no handshake to do that in, so client: { name, version } on execute() does the same job per call: it is what shows up as the assistant on the audit log and every execution.client you read back. Send it on every call; there is no session-level place to set it once.

await session.execute('kb_search', {
  arguments: { query: 'refund policy' },
  client: { name: 'chanl', version: '1.4.2' },
});

What's automatic

  • Metered as a session call. Every session.execute() call, including one that pauses on approval, counts toward the workspace's mcp_requests allowance, the same as an MCP tools/call would.
  • Audited. Every call, gated or not, writes an execution row with client (from the field above), caller ({ userId, kind: 'session' }), outcome, and, for a denied call, reason. Read it back with protobox.executions.get(id) or list a caller's whole trail with protobox.executions.list({ caller: 'user_123' }). See Observability & audit for filters and webhooks.
  • Policy-enforced. allow / approve / deny on the toolset apply identically whether the call arrived over MCP or REST. A deny-flagged tool is exposed by session.tools() and rejected at call time, not hidden.

Production notes

  • One session per conversation, not per user. Mirrors Per-user MCP sessions's guidance: revocation stays scoped to one conversation, and traces map 1:1 to it.
  • Revoke when the conversation ends: protobox.sessions.revoke(session.id).
  • session.execute() re-resolves identity and toolset per call, with no local caching. A session rebound with sessions.update() is picked up on the very next call, no new handle needed.
  • Store session.id, not a token, when driving everything server-side through the SDK. No bearer token is needed for this path, unlike handing a URL to an external MCP client (see Embed Protobox in your product).

Chanl-side integration checklist

The Protobox side of this path (session, tools, execute, approvals, executions, memory, knowledge, metering, and audit, all reachable with @protoboxai/sdk alone) is proven end to end by docs/goals/chanl-first-client-conformance.md. What's left is entirely in chanl-api, tracked there as its own cross-repo work:

  • Mint one Protobox session per Chanl conversation (principalId/actorId = the Chanl conversation/customer id) at conversation start, and revoke it at conversation end.
  • Store session.id next to the Chanl conversation record, never the session token, since this path never uses one.
  • Send client: { name: 'chanl', version: <chanl-api's own release version> } on every session.execute() call, so Protobox's audit log and metering attribute correctly.
  • Surface pending_approval results in Chanl's own UI (or route them to whatever approves Chanl-side actions today) instead of treating them as failures.
  • Decide whether Chanl calls sdk.knowledge.search()/sdk.memory.* directly (workspace-level) or through the session (conversation/end-user-scoped) per the guidance in step 5 above. Most Chanl use cases want the session-scoped form.

On this page