@odla-ai/chat
v0.13.4
Published
Realtime messaging and project discussions on odla-db: project channels, post threads, bottom thread cards, scoped live updates, DMs, and AI participants over one default-deny graph.
Maintainers
Readme
@odla-ai/chat
Realtime messaging and project discussions built natively on odla-db's realtime engine. Chat is a schema + default-deny CEL rules + injectable clients; humans and agents use the same rule-governed graph. No server to run — it rides the existing odla-db Durable Object.
import { createChatClient } from "@odla-ai/chat";
import { init } from "@odla-ai/db/client";
const db = init({ appId, endpoint: "wss://db.odla.ai", getToken });
const chat = createChatClient(db, { selfId: myAuthId, selfEmail, displayName });
const { id } = await chat.createChannel({ slug: "general", name: "General", kind: "public" });
const channel = await chat.getChannel(id);
if (!channel) throw new Error("channel creation did not become visible");
const unsub = chat.subscribeChannel(id, (messages) => render(messages));
await chat.sendMessage(channel, { body: "hello" });sendMessage also accepts structured refs and durable attachments.
They are written in the initial message transaction, so realtime consumers and
mention responders never observe a message before the content it refers to:
await chat.sendMessage(channel, {
body: "Review this logo",
attachments: [{
id: brandAsset.id,
name: "logo.png",
contentType: "image/png",
size: brandAsset.size,
path: brandAsset.path,
}],
});Persist stable private handles, not signed download URLs. The consuming application must reauthorize and resolve a handle before presenting bytes.
Ask the runbooks first. odla's operational procedures live in a database, not in this file:
npx @odla-ai/cli runbook ask "<question>"returns the current steps, and unlike anything written here it cannot be out of date. Use it before searching the web or working from memory. This README and the JSDoc in the shipped.d.tsare the version-matched API reference; a runbook is the procedure. Most tasks need an answer from both.
Model
Access is denormalized (no graph edges, to fit odla-db's single-hop ref and
pre-commit rule evaluation): a channel's memberIds (json) is the auth roster for
private/dm channels; messages and reactions carry a denormalized visibility +
audience. chat_membership rows are the per-user index and read-state, not the
source of truth for access. Message ordering + pagination use the indexed
createdAt cursor.
Pieces
CHAT_SCHEMA— push to/app/:id/schema.CHAT_RULES/chatRules({ orgDomain })— install at/app/:id/admin/rules.createChatClient(db, self)— the UI-facing client (channels, DMs, messages, feeds, threads, reactions, read-state, typing/presence).chatSkill({ db, channelId, self })— a@odla-ai/aiSkill so an agent reads and posts as a bot member.botTrigger({ id, agentId, persona, mention })— the commit-trigger config to register at/app/:id/admin/triggers; the odla-db commit hook fires it on human messages and dispatches to the chat-agent worker.chatIntegration— a descriptor bundling the schema, rules, and provisioning steps.createDiscussRoutes({ db, authz, ... })— mountable project discussion routes. When the injected admin client supportschanges(),GET /watchprovides authorized topic/inbox events from a server-issued cursor. Topic reads return bounded forward pages;DiscussClient.getTopic()follows them to preserve its whole-conversation contract without a hidden 200-post ceiling, verifies completeness against the atomic reply count, and fails closed after bounded retries/size.getTopicPage()is the explicit bounded primitive.
Hosted app backend
initDiscuss connects a running app backend to Registry's brokered Discussion
surface with the app's existing full backend key. Keep the key server-side.
The client may list, read, start, and reply to topics in its own project. On
odla-ai, it can reach only topics created by that exact app lifetime, while a
topic read still includes later replies from odla agents and collaborators.
import { initDiscuss } from "@odla-ai/chat";
const discuss = initDiscuss({
endpoint: process.env.ODLA_PLATFORM!,
appId: process.env.ODLA_APP_ID!,
env: process.env.ODLA_ENV as "dev" | "prod",
appKey: process.env.ODLA_API_KEY!,
});Hosted Code agents use the narrower runtimeDiscussSkill through Registry's
trusted tool broker. The model and its networkless runtime receive only tool
descriptors and results—never the app key or a shared-tenant credential.
Per-topic GET, PATCH, reply, and read-acknowledgement routes accept an optional
app query assertion. A mismatch returns 404 before an effect. Successful
reply responses include the authorized appId and topic subject, allowing a
trusted embedding proxy to scope and acknowledge a write without a separate
thread preflight.
Resumable project discussions
The Discuss route factory exposes one cursor stream for a topic
(?topic=<id>) or the caller's owned-app inbox (?app=<id> or all owned
apps). The first request to GET /watch returns an empty checkpoint. Pass its
opaque cursor on the next request; returned events have stable IDs and their
own cursors, so a consumer can persist progress midway through a page and
receive the remainder at least once.
The route rechecks app ownership before projecting transaction metadata into
messages/topics. Cursors are bound to the exact stream. A database restore,
copy, or truncated checkpoint returns HTTP 409 with
code: "checkpoint_required" and a replacement checkpoint—never an empty
success. The optional onWatch hook reports only low-cardinality scope,
outcome, counts, backlog state, and event age; it carries no app, topic, user,
event, or cursor identifiers.
Project discussions and browser UI
@odla-ai/chat/discuss is a Gmail-style project conversation model: one
project-backed channel contains root posts with replies under each post. The
optional @odla-ai/chat/ui browser kit renders a full-width inbox and pins
independently minimizable or dismissible thread cards along the bottom edge.
Several pins can remain open; a controlled topicId focuses one deep-linked
card without closing the others.
npm i @odla-ai/chat @odla-ai/ui preactThe browser kit version containing principal badges requires
@odla-ai/ui >=0.16.0 and Preact 10.
import "@odla-ai/ui/index.css";
import "@odla-ai/chat/ui.css";
import { DiscussSuite } from "@odla-ai/chat/ui";
const { groups } = await discuss.listGroups();
const authorizedAppIds = groups.map((group) => group.appId);
<DiscussSuite client={discuss} live={db} liveScope={authorizedAppIds} />;A host may pass initialCompose={{ appId, subject, body, refs }} to open an
editable new-post draft with structured references already pinned to the body.
Nothing is written until the viewer posts. When the draft is durable host route
state, use onComposeDraftClose to remove it after cancellation so reload does
not resurrect discarded work.
Each group includes the viewer's durable lastReadAt cursor and an exact
unreadCount of open, non-archived topics whose activity is later than that
cursor, matching the inbox's default Open view.
The cross-project rail renders those counts and refreshes them on the same live
or fallback signal as the inbox. A successful focused-thread read refreshes the
cursor and badges; a speculative or failed read never clears them.
live is the scoped odla-db WebSocket/query client. Complete bounded topic and
post snapshots update the inbox, unread counts, and mounted threads directly;
searches, later pages, oversized windows, and incomplete frames reconcile
through the authoritative routes. liveScope only narrows those queries by
logical app id; it grants no access. The short-lived socket ticket carries
the Registry-signed discussionProjectScopes claim for exact app
incarnations, and tenant rules gate every row's projectScope against it.
The suite reads the client's content-free connectionHealth() snapshot
and reports Connecting, Live updates, Reconnecting, delayed/stale
updates, or an offline connection without exposing query or message content.
Without a client—and while a client is unhealthy—the REST-backed views refresh
every LIVE_FALLBACK_MS (15 seconds) as a safety net. The inbox keeps one
fallback loop and refreshes every retained thread card from that same tick, so
minimized/background cards stay current without multiplying degraded-mode load.
Shared-tenant hosts must call ensureProjectLifetimeWritesOpen only after
resolving current, exact Registry write authority. It creates a new active
lifetime when needed and atomically upgrades an exact legacy active lifetime
whose write-state fields are still absent. Read paths use
requireActiveProjectLifetime (or getProjectLifetime) and never perform that
upgrade. Archive flows call suspendProjectLifetimeWrites; when no lifetime
exists it seeds the exact project directly as suspended, and when a legacy row
exists it normalizes directly to suspended without a transient open state.
Topic and list responses include a principals map of
DiscussPrincipalProfile values keyed by immutable principal id. A topic read
projects both visible authors and referenced agent targets, so activity rows
can name an agent before its first reply. The host's
DiscussAuthz.principalProfiles resolves those registry-authoritative friendly
names, handles, kinds, managed-agent purpose, and manager metadata; they
override mutable chat participant labels. Purpose is passed only to that exact
responder's system identity as quoted focus context. It cannot add tools,
project access, or authority, and mutable chat rows never supply it.
@odla-ai/chat/ui renders profiles with the shared
PrincipalBadge, including the friendly manager name for an agent. A host
resolveAuthor remains only a compatibility fallback, and unknown long
identity ids render as Member rather than leaking an opaque account
subject into the conversation.
The discussSkill responder applies that same authoritative profile projection
to read_topic, including each agent's manager, and never substitutes raw
principal ids when the directory is unavailable. Attached files appear as
bounded name/type/size descriptors with links rebuilt from validated tenant
paths through the existing authenticated /registry/discuss/attachments/*
route; persisted attachment URLs are not trusted. read_attachment accepts
only an attachment id first exposed by read_topic. The host then reloads the
exact topic and post, rechecks current project access, and performs a bounded
private-storage read—never a model-supplied path or URL. Raster images and PDFs
use capability-checked multimodal tool-result blocks; UTF-8 text has a smaller
context cap. The broker keeps ordinary text/control JSON under the configured
input budget while separately bounding aggregate decoded media (4.5 MiB) and
encoded base64 overhead. SVG, unsupported, spoofed, cross-topic, malformed, and
oversized content fail closed, and all returned content carries
tool_untrusted:read_attachment taint. A dispatched reply keeps the topic's
threadRootId while setting
replyToId to the exact source post.
Every successful hosted read_topic appends an opaque, replay-stable
contextReceipt inside the existing 64 KiB tool-output cap. The host derives
it from the deterministic turn mutation plus topic/source and context-output
ordinal. That mutation identity is unavailable to the model before the tool
result, but it is not a secret or cryptographic authority. Each later hosted
context tool (read_attachment, search_collaborators, or
inspect_reference) invalidates the current receipt before its handler and
appends a rotated receipt to its returned success or safe error. Multimodal
outputs retain their media and receive the marker as a trailing text block; a
thrown context failure clears grounding and requires read_topic again.
The hosted reply_to_topic and propose_brand_change schemas require the
latest receipt, and the wrapper consumes an exact match before the outcome
handler. A model therefore cannot batch context production and an outcome in
one assistant response; it must observe the latest result in a later inference.
A failed effect requires another read. Final-prose fallback uses the same
latest-context generation boundary and the reply sink's taint allowlist, so
prose beside a context call or after forbidden tool output is not posted. This
is only a model-turn barrier, never identity, authorization, idempotency, or a
content signature; Registry still rechecks the exact turn, source, project
authority, and effect digest. Receipt replay is deterministic, but dynamic
broker tool payloads are still live reads rather than byte-for-byte captured
replay snapshots. The discuss.reply policy therefore requires at least two
model calls per run; allow at least three for read → inspect/search → outcome
workflows (the default remains six).
Hosted product tools use the same discovery boundary. While rendering
read_topic, the skill remembers only complete structured Brand, CRM, and Code
refs that actually fit in the returned context; literal markup and truncated or
omitted refs confer nothing. inspect_reference accepts only the exact
{ kind, id } of one of those refs—never a URL, app id, path, or row key—and
returns a bounded product-owned summary after the host rechecks the running
turn and product authority. A Brand palette inspection also returns at most 24
validated semantic roles with normalized #rrggbb values and optional names;
the message card renders that same product-owned projection with
@odla-ai/ui's PaletteStrip. An exact brand:asset inspection can return
bounded private PNG, JPEG, GIF, WebP, or PDF bytes to a capable hosted model.
The reader mints its own short-lived object URL, never accepts or returns a
URL/path/app/storage id, and rechecks book membership, the linked asset row,
content type and magic, digest, size, product authority, and running turn after
I/O. Registry and chat-agent validate the closed result again, and inference
admits media only from the exact paired inspect_reference tool use under the
existing 4.5 MiB aggregate media budget and untrusted-tool taint. Discussion
does not persist private previews; until a current-auth proxy exists, the human
card truthfully opens the native Brand surface instead.
propose_brand_change is the sole product mutation:
it accepts a discovered brand:book, one closed Brand facet
(palette, typography, voice, logo, or imagery), and bounded payload
and rationale. The host commits an open brand:proposal and the turn's one
visible Discussion reply before the tool succeeds. A later reply is rejected,
and approval remains an exact human action in Brand. There is no CRM/Code
mutation, Brand approval, or arbitrary product-call tool in Discussion.
Inspection and attachment results carry distinct tool taints. Only the bounded
topic reply/delegation sink and inert Brand-proposal sink explicitly accept
those two labels plus the model-inherited label that every runAgent tool call
necessarily carries. That model label is safe here only because the sinks
cannot escape the exact topic/project or human-review-only proposal boundary;
web, operator-pasted, and proposal-result taints remain outside their
allowlists and fail before a handler can write.
DiscussOperator is likewise credential-bound: the host derives its principal
and credential from the request's bearer credential, so clients never choose
the author. Manager metadata explains who is accountable for an agent but does
not confer project authority. Each route resolves live, action-level project
authority; accepted agent writes persist that exact grant's grantor, id, and
version rather than credential-level grant hints.
Group rosters remain display state, never capability state. Discussion re-resolves the host's exact app incarnation and action-level authority after awaited roster/content work, immediately before each store effect, and again before releasing read or write results. A revoked or suspended managed agent therefore cannot continue through a stale channel member row. The people resolver likewise offers an agent only while its principal, exact-project write grant, and private Chat runtime admission are all live; human collaborators do not require runtime admission.
Cross-project rails should implement
DiscussAuthz.revalidateProjectScopes with the host authority store's set
lookup. Discussion still performs the same post-read exact-incarnation check,
but one set revalidation replaces one remote authority request per project.
Shared-tenant lifetime admission uses getProjectLifetimes for the same reason:
project count does not multiply database round trips. Hosts that omit the
optional set hook keep the conservative per-project fallback.
The @ picker opens above the bottom-edge composer and returns authorized
people and named agents. Selecting an @agent candidate writes a structured
agent/<principal-id> reference; selecting odla writes
agent/agent_odla, while an isolated human-authored plain @odla remains
compatible. Agent-authored delegation is stricter: it requires one exact
structured agent target, the host resolves the sender's live write grant, and
the resulting envelope is bound to the source principal, credential, message,
grant revision, and depth 1. Any distinct active named agent with a live
discussion.write grant for that exact project may be the target. Ordinary
agent prose, self-delegation, and unbound replies cannot cascade or impersonate
another principal.
Hosted responders receive search_collaborators, a read-only broker tool bound
to the exact running turn and project lifetime. It returns only non-self agents
whose principal, project grant, and private runtime binding are all currently
live, with Registry-owned display name, handle, purpose, accountable manager,
and exact paste-ready structured agent markup. A reply naming one result goes
through the broker rather than a responder's direct Chat credential. Every
hosted odla-pm Discussion responder is keyless: authenticated Registry
state, thread, and reply operations re-admit the exact dispatch, project
lifetime, source, and running turn. Registry reconstructs both principals and
runtime provenance. Before a reply, delegation, or Brand-proposal effect, one
atomic Registry D1 batch claims the exact service lifetime, dispatch, live
authority, and request digest. Before any Chat, Brand, or provider effect,
Registry asks Server to atomically bind that id and digest to the exact running
job attempt, source, target, trigger, project, and service lifetime. The same
claim remains replayable after the turn becomes terminal; stale attempts and
conflicting ids fail closed. The exact Chat outcome is stamped with that
authorization id and digest, so Registry can commit the claim or reconcile it
after an ambiguous response. Reply and delegation content still commit under
exact project, source, and outcome guards in one Chat transaction. An exact
replay returns the same durable outcome without another effect. Stale turns,
conflicting replay content, authority changes before the claim,
self/multi-target handoffs, and agent-authored cascades fail closed. Brand
changes remain open proposals until a human explicitly approves them.
Non-identity refs in this machine-authored bridge are reduced to safe unlinked
typed chips; native Brand, CRM, and Code resolution keeps its own user and
product authority boundary.
Discussion references are also typed across product boundaries. Known kinds include Brand books/assets/palettes/proposals/receipts, CRM records/activities, and Code sessions/candidates/publications/deployments. Agents and the CLI write the same structured markup as every other reference, including compound native ids when the product needs them:
@[Homepage candidate](code:candidate/ccand_…)
@[Palette proposal](brand:proposal/book-id/proposal-id)
@[Lead record](crm:record/person/record-id)The server discards caller-supplied href and meta before storage and asks a
host resolver to rebuild presentation under the current project authority.
odla's Registry resolver can discover Code because Code state is Registry-owned;
it returns only the direct signed-in human owner's rows in the current app
lifetime. A managed-agent credential cannot inherit its manager's private Code
sessions. Publications open the exact native GitHub review, while candidate and
deployment refs open the owning Code workspace for fresh review.
Brand and CRM are app-owned integrations, so Registry never scans their
databases with infrastructure authority and never invents presentation for an
opaque id. Search and exact resolution call the current production app with a
30-second, read-only assertion bound to the exact product, product capability,
canonical endpoint audience, principal, app incarnation, and environment.
Agent assertions also bind their manager and live grant id/version. The native
route checks its product ACL and exact row existence, returning only a
same-origin link plus bounded product-authored label, summary, status, and
destination. Registry rechecks the assertion after product I/O, so a revoked
grant or replaced app/link cannot leak the response. Ordinary agent handshakes
do not include CRM access; an owner must explicitly select the read-only
crm.read option. Brand Studio selects the book and proposal review; Chapter
CRM selects the record and focuses an activity in its notes tab. The ref is
navigation, never proof of approval. Brand
proposal decisions stay on Brand's exact human receipt route, Code publication
and deployment stay on Code's native owner-review routes, and CRM mutations
stay behind the app's CRM authorization. Discussion exposes no generic approve
or write-through tool.
The current owner/co-owner-configured HTTPS app link is a trusted outbound product destination. Request input cannot choose an origin. Provisioning must attest that link to the deployed app; Registry uses manual redirects, a short deadline, a streamed byte ceiling, JSON/item/field limits, and post-I/O authority checks. This is an explicit owner trust boundary, not a general URL fetcher or private-network discovery API.
Every exact structured managed-agent mention (and the compatible human
@odla handle) invokes discussion replies—there is no feature flag or enable
switch. The System AI discuss.reply policy configures the provider, model,
prompt, and per-run budgets. Accepted writes visibly name the target and show
queued/working activity. For a human post, DiscussAuthz.resolveAgentDispatch
returns each Registry-validated exact trigger, target principal, and project
lifetime. The generated source id binds that complete batch to the admin
transaction. odla-db re-matches every entry against the final row and current
trigger, then inserts the source, turn ledger, and durable outbox in one SQLite
transaction. If a trigger is replaced or any target no longer matches, HTTP
503 leaves the source absent; a successful post cannot lose a requested turn
in the readiness-to-write gap. Ordinary discussion and reads remain available
during a responder outage.
odla-db retains a durable per-dispatch outbox row until chat-agent acknowledges
an explicit terminal outcome. The payload-free agent-turn ledger is keyed to
the exact source message and reports queued, running, retrying,
succeeded, failed, or cancelled, with attempts and the next retry time.
Status projection selects the newest accepted turn independently for each exact
target agent, and the dock derives those current requests from thread order.
An old failure therefore cannot mask newer running or successful work, while
different requested agents retain independent current states. Every retained
card header shows one compact count per represented lifecycle—or an explicit
mixed summary—while minimized. It polls only non-terminal work through the
exact-topic status route. The expanded thread renders one row per target with
its authoritative friendly principal badge and newest exact source/target
state. A succeeded row clears only when that exact target's reply to that exact
source is visible. Background replies mark that card unread; focusing it
acknowledges the topic. Realtime subscriptions drive normal refresh, while one
suite-owned degraded-mode timer refreshes every retained card instead of
starting a timer per card. The UI never infers failure or continued work from
elapsed wall-clock time. Drains are bounded and stable
effect identities make retries and one final ambiguous-response reconciliation
safe. Missing credentials, unavailable model configuration, denied delegation,
and exhausted or non-retryable delivery errors become durable failures.
Generic AgentJobs remain separately inspectable and retryable by an operator.
Chat-agent credentials
The optional @odla/chat-agent Worker fails closed unless
CHAT_DISPATCH_SECRET is configured. It does not accept a platform-wide DB
admin token.
Every hosted odla-pm Discussion responder uses ODLA_PLATFORM plus the
dedicated DISCUSS_AI_SECRET shared with Registry, with no direct tenant bot
key. Every broker request re-admits the accepted dispatch and exact project
lifetime. The bounded authenticated surface covers policy and inference;
runtime state, bounded thread, and reply; principal profiles and attachment
inspection; collaborator search and one-hop delegation; product reference
inspection; and open-only Brand proposal brokerage. The state, thread, and
reply operations are the direct Chat data operations; they are not the
broker's entire surface. Registry alone holds DB_ADMIN_SECRET and the exact
dispatch/project/turn authority. Do not mint or configure any
ODLA_BOT_TOKENS["odla-pm"][agentId] entry for hosted Discussion, including
agent_odla.
Generic per-tenant Chat responders and Brand triggers outside hosted
odla-pm Discussion still use ODLA_BOT_TOKENS. Store it as a Worker secret
containing a nested JSON map from exact tenant id, then exact agent principal,
to a scoped app key:
{
"support-chat--prod": {
"agent_support": "odla_sk_..."
},
"brand-studio--prod": {
"agent_brand": "odla_sk_..."
}
}For a full generic Chat responder, mint one key for that exact agent and the six agent-visible Chat content namespaces:
{
"mode": "rules",
"agentId": "agent_support",
"namespaces": [
"chat_channel",
"chat_membership",
"chat_message",
"chat_reaction",
"chat_participant",
"chat_topic"
]
}chat_project_lifetime is deliberately absent: it is an admin-only hosted
Discussion synchronization namespace, not agent data-plane authority. Brand
trigger keys use the same nested tenant/principal map, plus only the exact
Brand semantic profile and namespaces documented by @odla-ai/brand; never
widen them to raw Brand writes. agentId accepts only an agent_* identity, so
a scoped credential cannot impersonate a human.
Set DISCUSS_AI_SECRET to the same dedicated random value on
odla-chat-agent and odla-apps, and verify chat-agent /health before
enabling writes. /health reports separate
capabilities.hostedDiscussion and capabilities.genericAgent readiness.
Dispatch authentication plus the configured hosted broker determine the HTTP
status, so hosted Discussion remains 200 when the generic token map is
absent. A missing or malformed-present ODLA_BOT_TOKENS map is nevertheless
reported as generic-agent unready with a content-free reason; a valid nested map
is ready. The private /svc/agent-admission check proves one exact runtime
without returning configuration or keys. Neither route makes a remote broker
call. Production verification must exercise an authenticated state read and
a bounded thread read, then one canary reply when it is safe to create
visible content. Rotate CHAT_DISPATCH_SECRET with odla-db and
DISCUSS_AI_SECRET with odla-apps as two independent pairs. The worker has no
wildcard tenant or platform-machine-token fallback.
See this installed README and the exported TypeScript declarations/JSDoc for the version-matched API. The rendered public reference is at https://odla.ai/docs/packages/chat.
License
MIT
