channel docs

channel / concepts

Runtime state

Multichannel delivery needs working memory: locks so one conversation gets one reply at a time, queues for messages that arrive mid-reaction, subscriptions that outlive a deploy. The channel domain keeps all of it in four internal InstantDB entities — no Redis, no extra infrastructure, nothing for you to operate.

All state, one database

The delivery runtime is backed by a state adapter that persists everything — key-value pairs with TTL, per-conversation locks, durable subscriptions, ordered queues — on the same InstantDB app that holds your channel_messages. Pushing the channel schema provisions all of it; there is no second datastore to deploy, secure or monitor, and a fresh process resumes from exactly where the previous one stopped.

These four entities are internal to the domain. They are owned and mutated exclusively by the delivery runtime; your application never queries them, never writes them, and should never link to them. The public surface remains channel_messages and the createChannels callbacks — runtime state is plumbing that happens to be visible in your schema.

Entity reference

proptypedescription
channel_stateKV + TTLGeneral key-value store: unique indexed key, JSON value, optional expiresAt. Backs caches, dedupe markers, set-if-not-exists guards and bounded lists. Expired rows are deleted lazily on the next read.
channel_lockstoken + TTLOne row per conversation (threadId is unique), holding a random token and an expiresAt. Guarantees a single active delivery per conversation; the token ensures only the holder can extend or release.
channel_subscriptionsdurable setThe set of platform conversations the runtime is actively following, one row per threadId. Because it is a table rather than process memory, subscriptions survive restarts and redeploys.
channel_queuesordered entriesPer-conversation FIFO: threadId plus a monotonic seq and a JSON entry. Holds messages that arrive while a handler is already running, dequeued in seq order with a bounded depth.

Locks: one delivery per conversation

When an inbound message starts a reaction, the runtime first acquires the lock for that conversation. Acquisition follows a simple protocol: if a non-expired lock row exists, the attempt fails and the caller backs off; if the row is expired, it is taken over with a fresh token; if no row exists, one is inserted — and the unique index on threadId turns a concurrent double-insert into a caught error, so exactly one racer wins.

the lock lifecycle
acquire(threadId, ttl)  ->  { threadId, token, expiresAt } | null
extend(lock, ttl)       ->  true only if the stored token still matches
release(lock)           ->  deletes only if the stored token still matches

The token check on extend and release is the fencing mechanism: a handler that stalled past its TTL and lost the lock to a newer acquirer cannot release the newer holder's lock by accident. Long reactions extend the lock as they go; a crashed process simply lets its TTL lapse, and the conversation unblocks on its own.

Subscriptions and queues

Subscriptions record which conversations the runtime cares about. On restart the runtime reads the table back and resumes following every conversation it was following before — no platform re-handshake, no lost threads after a deploy.

Queues absorb concurrency instead of dropping it. If a user sends three messages while the agent is still reacting to the first, the lock rejects parallel handling and the runtime enqueues the extras with increasing seq values. When the running handler finishes and releases the lock, queued entries drain in order — each one a fresh reaction against an up-to-date context. Queue depth is bounded, so a flood degrades by refusing new entries rather than by growing without limit.

Atomicity is best-effort in v1

The v1 state adapter does not use database-level compare-and-swap. Concurrency safety leans on two guards: unique indexes on channel_locks.threadId and channel_state.key make conflicting inserts fail loudly (the insert race is safe), and expiry is enforced on read, so a stale row is treated as absent the moment any reader sees it. What this does not cover: a read-then-update on an expired-but-present lock can in principle race two takeover attempts, and TTL correctness assumes reasonably synchronized clocks. In practice the lock TTLs and token fencing make the window small and the failure mode mild — at worst a duplicated reaction attempt, never a corrupted record. If your deployment needs strict mutual exclusion under hostile concurrency, treat this as the known limitation of v1.

The trade was deliberate: best-effort atomicity on InstantDB buys zero extra infrastructure, full reactivity over the runtime's own state, and one consistent backup/permissions story for the whole domain. The adapter interface is narrow, so a stricter backend can replace it without touching anything above.

Next

database…