Durable Chat
Use Durable Chat when a server must keep a conversation and recover permitted work after a host restart. ShappyChat supplies the chat UI and a backend adapter. Pi Durable runs the conversation in a celld host. Your app keeps its own records and supplies its MCP tools.
Status: The adapter is in the source checkout. The SDK is not on npm. A working integration also needs a configured celld host, model account and app access checks. The Notes demo does not start this host.
Choose the connection
| Part | Responsibility |
|---|---|
| Browser | Shows the shared ShappyChat UI through RemoteShappyChatRuntime |
| Trusted backend | Checks access and binds the account, workspace and client to the correct cell |
CelldShappyChatRuntime |
Sends text to the bound cell and reads its snapshots |
| celld host with Pi Durable | Stores the conversation, admits input and runs work under its recovery policy |
| App MCP server | Checks current permission and executes app operations |
Keep the cell service token in the trusted backend. Do not import CelldShappyChatRuntime into browser code. Scope strings in an adapter are not access checks. Your backend must check that the signed-in user can use the bound cell.
The adapter can also connect directly from a trusted Node process. Use the bridge when the browser must use the existing remote runtime protocol.
Get the source SDK
Use Node.js 22.19 or newer. From the ShappyChat checkout, install the pinned dependencies and build the SDK:
npm ci
./app.sh build
The build reads app/sdk.ts and writes the Node entry to sdk-dist/node.js. This entry exports CelldShappyChatRuntime, CelldRuntimeOptions and createRuntimeBridge. The browser entry is sdk-dist/embed.js. The styles are in sdk-dist/style.css.
The examples below use these local files. They do not require a published package. If you receive an SDK package file, verify its version and exports before use. A prior package file can have different exports.
Connect a trusted backend directly
Save this file as durable-client.ts in the checkout root. Set the URL to one provisioned cell route. Obtain the token from your private backend configuration.
import { CelldShappyChatRuntime } from './app/sdk.js';
function required(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`Set ${name}`);
return value;
}
const runtime = await CelldShappyChatRuntime.connect({
url: required('DURABLE_CELL_URL'),
token: required('DURABLE_CELL_TOKEN'),
conversationId: required('DURABLE_CONVERSATION_ID'),
scope: {
accountId: required('DURABLE_ACCOUNT_ID'),
workspaceId: required('DURABLE_WORKSPACE_ID'),
},
});
try {
await runtime.submit({
requestId: required('DURABLE_REQUEST_ID'),
message: 'List the records that I can read.',
});
console.log(runtime.getSnapshot().status);
} finally {
await runtime.close();
}
Run it with the installed TypeScript loader:
node --import tsx durable-client.ts
These variable names belong to this example. They are not automatic ShappyChat host settings. Use the same request ID and message when you retry an uncertain submission. Do not create a new ID until you know the state of the original request.
The conversationId and scope options label the ShappyChat snapshot. They do not select or authorize a cell. The url selects the cell. An optional clientId requires the cell snapshot to report the same client grant ID.
submit() sends the input and waits for pending work to end. A terminal failure can cause this method to throw. Keep the stored snapshot and original request ID available for inspection.
Connect the browser through the bridge
Save the backend file as durable-bridge.ts in the checkout root:
import { createRuntimeBridge } from './app/sdk.js';
function required(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`Set ${name}`);
return value;
}
const bridge = await createRuntimeBridge({
storageDirectory: required('DURABLE_BRIDGE_DATA'),
port: 8849,
token: required('DURABLE_APP_TOKEN'),
allowedOrigins: [required('DURABLE_APP_ORIGIN')],
scope: {
accountId: required('DURABLE_ACCOUNT_ID'),
workspaceId: required('DURABLE_WORKSPACE_ID'),
},
celld: {
url: required('DURABLE_HOST_URL'),
token: required('DURABLE_CELL_TOKEN'),
},
});
console.log(bridge.url);
process.once('SIGINT', () => void bridge.close());
process.once('SIGTERM', () => void bridge.close());
node --import tsx durable-bridge.ts
This example starts a loopback bridge for one trusted scope. It does not supply public multi-user sign-in. The app token and the cell token are separate credentials. Obtain the app token at runtime through your authenticated app. Do not include either token in a public bundle.
The bridge appends /bots/<cell-id> to DURABLE_HOST_URL. It computes the cell ID from the runtime scope and ShappyChat conversation ID. The host must supply this route and bind it to the correct cell. The bridge does not install celld or configure a model. Do not map all these routes to one shared cell.
For external clients in operator mode, set celld.clientBindings with a separate { url, token } entry for each registered client ID. The client must have the accounts connection grant. Configure the cell host with the same client ID and its scoped App MCP token. A shared operator token does not establish this grant. Do not grant host UI access to a celld client.
The bridge keeps conversation membership in its SQLite database. The cell keeps the messages. When you set celld, the bridge does not convert the local history that you have. The bridge rejects local history without the matching cell authority record. Use a separate, verified history import.
Mount the shared chat
Save the frontend example at the checkout root. Bundle the browser SDK with your app. Supply backendUrl and appCredential through your authenticated app configuration.
import {
RemoteShappyChatRuntime,
mountShappyChatDrawer,
} from './sdk-dist/embed.js';
import './sdk-dist/style.css';
export async function openDurableChat(
backendUrl: string,
appCredential: string,
) {
const response = await fetch(`${backendUrl}/api/conversations`, {
method: 'POST',
headers: {
Authorization: `Bearer ${appCredential}`,
'Content-Type': 'application/json',
},
body: '{}',
});
if (!response.ok) throw new Error('Cannot create the conversation');
const { conversationId } = await response.json();
const runtime = await RemoteShappyChatRuntime.connect({
url: backendUrl,
token: appCredential,
conversationId,
});
const drawer = mountShappyChatDrawer(document.body, {
runtime,
presentation: 'drawer',
enableAttachments: false,
});
drawer.show();
return { drawer, runtime, conversationId };
}
Call openDurableChat() with the authorized bridge URL and app credential. Save the returned conversation ID in your app. On the next visit, connect with that ID instead of creating another conversation.
The bridge must allow the exact app origin. See Shappy Backend and Security and data for client access rules.
Keep your own chat UI
Your app can keep its current layout and controls. Connect RemoteShappyChatRuntime to the bridge. Use getSnapshot() for the initial state and subscribe() for changed state. Send text with submit({ requestId, message }). Use cancel() for an explicit Stop action. A ShappyChat drawer is optional.
import { RemoteShappyChatRuntime } from './sdk-dist/embed.js';
export async function connectCustomChat(
url: string,
token: string,
conversationId: string,
showMessages: (messages: unknown[]) => void,
) {
const runtime = await RemoteShappyChatRuntime.connect({
url, token, conversationId,
});
showMessages(runtime.getSnapshot().messages);
const unsubscribe = runtime.subscribe((state) => showMessages(state.messages));
return {
send: (requestId: string, message: string) => runtime.submit({ requestId, message }),
stop: () => runtime.cancel(),
close: async () => {
unsubscribe();
await runtime.close();
},
};
}
Use your renderer for the returned messages. Show running, interrupted and error states from the snapshot. Keep unsent text in your UI. Preserve the original request ID when the response to a submission is uncertain.
Host contract and configuration
The adapter expects these service actions relative to the bound cell URL:
| Action | Input | Result |
|---|---|---|
GET snapshot |
No body | Cell conversation ID, model, agent settings, messages, pending count and submission state |
POST submit |
{ requestId, message } with text input |
HTTP 202 admission receipt from the plugin host |
POST cancel |
{} |
Cancellation result |
Each request uses the cell service bearer token. Admission confirms that the cell stored the input. It does not confirm a model response.
For the current PluginConversationCell host, configure the account ID, provider URL, model ID, wire API, service token and conversation definition ID. Configure the App MCP URL and its scoped token. Keep supported definitions available for unfinished work. The host can supply provider keys and retained definitions. Client-bound cells also need the matching client grant ID.
These are host responsibilities. The adapter does not discover models, grant Bot access or provision Workers. Use your host's deployment instructions for these operations.
ShappyOS guarded submit is a different interface
The ShappyOS guarded POST /api/product/chat/submit route takes { scopeId, botId, requestKey, text }. It returns HTTP 200 with an admission receipt. It requires a principal with chat.submit capability and the Bot scope. The App management token does not grant this access.
The cell adapter uses { requestId, message }. Do not set its URL to the guarded route. The celldChatPort transport uses an input wrapper around the guarded body. Supply a compatible handler for that transport.
The current guarded catalog has no public chat snapshot, stream, recovery or cancellation operation. The service actions above are internal host actions. They are not public App management routes.
Updates, closure and recovery
The cell adapter reads snapshots every 500 ms while it has subscribers. While it waits for a submission, it reads every 250 ms. The bridge sends changed snapshots to browsers through server-sent events.
This is snapshot delivery. The adapter sets streamMessage to null. It does not provide model token streaming.
| Action | Effect |
|---|---|
drawer.hide() or drawer.unmount() |
Closes or removes the UI. Accepted work continues. |
runtime.close() |
Stops this client's updates. It does not call cell cancellation. |
runtime.cancel() or the chat Stop control |
Sends an explicit cancellation request. |
| Reconnect to the same bound cell | Reads the stored conversation. It does not resubmit the last message. |
celld can wake stored work after a host restart. Recovery uses the saved conversation definition and its replay policy. A model request can repeat during recovery. An uncertain external operation can require inspection before a retry. Durable storage does not make every tool operation safe to repeat.
Current limits
- Text input only. The adapter does not support file attachments or appended context.
- App MCP tools only. This adapter does not support browser tools, tagged UI controls or host UI tools.
- Configure the model, reasoning and instructions at the host. The adapter rejects changes to these settings.
- No automatic local-history migration or production cell provisioning.
- No model token streaming. No public guarded observation or cancellation API in the current ShappyOS catalog.
- This guide does not publish or deploy a standalone SDK.
The Notes example uses a scripted provider by default. Its real MCP operations do not establish real model inference. Set a real model endpoint for inference tests. A successful SDK build or this documentation preview does not test host recovery or model execution.
See Embed the chat for presentation options. See Runtimes for local alternatives.