@fsaos/gateway
The FSAOS Gateway SDK — reverse-engineered from the deployed production bundle into clean, maintainable TypeScript source.
What This Is
This is the source code for the 51 KB IIFE bundle that powers every FSAOS page today. Previously, only the minified bundle existed on R2 with no source repo. This package reconstructs the full TypeScript source with proper types, JSDoc comments, and a reproducible build pipeline.
Architecture
The SDK is loaded as a <script> tag by the dispatch worker and exposes window.__FSAOS_GATEWAY__ as a global. Components access it through the require() shim in dom-mount.js.
CDN Scripts (loaded before gateway.js):
1. supabase-js@2 → window.supabase
2. react@18 + react-dom → window.React / window.ReactDOM
Gateway SDK:
3. gateway.js → window.__FSAOS_GATEWAY__
Mount Runtime:
4. dom-mount.js → require() shim + mount()
5. Component IIFE → window.__FSAOS_COMPONENT__
6. __FSAOS_MOUNT__() → renders the component
Location vs. Access — the two kinds of read
Two questions look similar but are answered by different calls. Getting this right is the difference between a correct UI and one that shows a member items that aren't theirs.
- Location — "what is at this path / of this type in this account?" →
useItems,useList,useTree. These scope to the current account and return what's there. They do not know who you are within the account. - Access — "what can THIS actor reach in THIS context?" →
useResolvedItems. Resolved server-side by the kernel: union of account / member / space / system / licensed tiers, frame-bounded to the acting principal, preference-cascade aware.
Rule of thumb: if the answer should differ depending on who is asking or which channel they are in — connectors, tools, or anything member-scoped — use useResolvedItems. The SDK never computes frames or permissions client-side; it forwards to the kernel and renders the answer.
Exports
Error
EnforcementDeniedError— Error class for enforcement denials (rules, access, entitlement)
Session Management
initSession()— Initialize domain-scoped session viaGET /d/{hostname}/initinitializeFsaos()— Canonical one-call domain session + tracking bootstrapclearSession()— Clear cached sessiongetSessionEntry()— Get current session entry (or null)getAccessToken()— Get current auth token (waits for first auth event)setCachedToken()— Manually set auth token (for embed tokens)getScope()/setScope()/subscribeScope()— Active scope (account/switcher) accessors + subscription
Gateway Call
gatewayCall(method, params)— Core RPC dispatcher:POST /d/{hostname}
Public Page Forms
submitForm(capabilityId, fields, options?)— Submit through the public domain gateway to the form dataset bound to the current published page release. The browser supplies only the declared capability name, current page URL, and field values; it never receives a dataset identity or write credential. This works from the IIFE, ESM, or npm build.FormSubmissionError— Includes the stablefillId, HTTP status, and error code so an uncertain submission can be retried without creating a second fill.
const receipt = await submitForm('hero-optin', { email });
if (receipt.next) window.location.assign(receipt.next);
Plain HTML can use the same contract without loading the SDK:
<form method="post" action="https://fsaos-mcp-gw-rust.fly.dev/d/launch.example.com/actions/hero-optin">
<input type="hidden" name="_page_path" value="/offer">
<input type="email" name="email" required>
<button type="submit">Continue</button>
</form>
Shared Instances
supabase— Shared Supabase client instancequeryClient— Shared TanStack QueryClient instance
Query Key Factory
vfsKeys— Deterministic query keys for all VFS data
Channel Room Modes
Channels contain principals as members. AI is represented as a principal/member, not as a separate channel type. The SDK exposes the room mode through Channel.aiMode (Agent System Master Spec D17; kernel revision b7c4d2a9e1f3, 2026-10-29):
Channel.aiMode | Meaning |
|---|---|
"open" | The floor decision runs on every message; whoever it names answers. |
"active" | Only the on-duty agents answer a person (Channel.activeAgentIds; absent = every agent seated in the channel). An agent's message wakes another agent by mention only. |
"strict" | Mention or named recipient only. |
"hand_raise" | The floor decision runs on a person's message but nobody is woken; its answer arrives as the conversation.hands_raised event (subscribeToEvents) and the person picks who speaks, an explicit-recipient send. |
Mentions, a named recipient and trigger_ai answer in every mode. The kernel stores exactly these four values (CHANNEL_AI_MODES) and refuses any other at create-channel (CHANNEL_AI_MODE_INVALID); the legacy on/off rows were rewritten on → active, off → strict. A row the kernel has not rewritten is read the same way:
| Legacy/backend value | SDK value |
|---|---|
"on", "auto" | "active" |
"off", "mention", missing or unknown | "strict" |
Chats, Threads and Breakouts
A channel is a chat (a DM or a group) and its members are its audience (Agent System Master Spec D21, 2026-10-30). Inside a chat every message is one thing, a message, with two flags the kernel computes on each top-level row:
| Flag | Meaning |
|---|---|
ChannelMessage.reply_count > 0 | A thread: a side conversation inside the chat (useChannelMessages(...).threads). |
ChannelMessage.member_count > 0 | A breakout: the thread root has members of its own, and only they (and the owners above it) read and post in it; the kernel hides it from everyone else (useChannelMessages(...).breakouts). |
A chat created with member_ids is born auto_thread: false (messages happen in the channel feed); a member's own agent channel keeps auto_thread: true. Agents hand work to other agents in other chats with the todo tool's assign_to / report_to; the assignee is woken in that chat and the delegating agent is woken in the original conversation when the work completes, fails or needs input.
The SDK does not decide when AI should respond, spawn agents, or infer agent identity. It forwards gateway calls and normalizes read models; the kernel owns channel policy and message-trigger behavior.
Members, Seats and Agents
The typed surface of the membership doors (Agent System Master Spec D13–D21). Each function forwards one gateway call with the exact params the kernel reads and returns its receipt; the hooks (useInviteMember, useAcceptInvite, useSeatMember, useCreateAgent, useAnswerInput) wrap the same calls with the cache invalidation each needs.
| Call | Kernel door | What it does |
|---|---|---|
inviteMember({ email, target_scope_id, role? }) | invite-member | A member item, pending until accepted. role is their standing in the account (ladder role, default editor; ROLE_ABOVE_INVITER if above yours). Returns member_id. |
seatMember({ chat_id, member_id, role? }) | link (has_member) | Seats them on one chat (a channel) or breakout (a thread root). role goes in the edge context and is their role on that chat only; absent reads as editor. There is no metadata on an edge. Seat an invited member right after the invite: the roster shows the seat as pending and it opens on accept. |
acceptInvite({ member_id }) | accept-invite | The member turns active; can_do on every chat (and thread/breakout under it) they are seated on is false before this call and true after. No second seating step. |
createAgent({ display_name, description?, role_path?, character?, upload?, instructions? }) | create-agent | One transaction: identity, home member item, instructions/ blocks, account-wide role, seats in every space you are in. role_path must already exist (ROLE_NOT_FOUND); omit it for the user-default role. Only a person may call it. Returns principal_id, handle, member_id, member_path, seats. It creates no chat: to talk to the agent, create-channel with member_ids: [principal_id] births a DM. |
answerInput({ step_id, answer, input_request_id? | input_request? }) | todo-control answer_input | Answers a step that is awaiting_input, with either the request id or the request object read from the step (TODO_INPUT_CHANGED if the step moved on). The kernel records input_response, returns the step to pending and wakes the assignee; the agent decides the next step. Nothing mechanical reads the answer as yes or no. |
update with type_data merges at the top level: each key sent replaces that whole key, keys not sent are kept ({ type_data: { ai_mode: 'strict' } } changes the mode and nothing else; _remove_keys: ['x'] deletes a key). A channel's name (its path) is governed and refused; change type_data.display_name.
VFS Operations (pure async functions)
normalizeItem(raw)— Normalize raw gateway response toVFSItemfetchVfsItem(path)— Fetch a single itemfetchVfsItemById(id)— Fetch a single item by UUIDfetchVfsChildren(path)— Fetch children (also warms item cache)fetchItems(filter)— Fetch items with structured filters: onetype, orany_ofgroups spanning types;fields,field_in,discovery, sort and offsetitemsFilterFromQueryFilter(typeData, { scope?, limit? })— Turn a stored saved filter (aquery_filterchild of an arrangement) into the filteruseInfiniteItemstakesfetchTypeDefinitions(scopeId?)— Fetch type definitions asMap<string, TypeDefinition>fetchEdgesForItem(itemId)— Fetch edges (incoming + outgoing)fetchVfsTree(path, depth?, types?, limit?)— Fetch recursive treefetchMemberFocus(scopeId, types?, limit?)— Fetch member-focus itemsfetchResolvedItems(contextId, itemType)— Imperative form ofuseResolvedItems(access resolution)inviteMember/acceptInvite/seatMember/createAgent/answerInput— see Members, Seats and Agents
Cache Invalidation
invalidateChildren(path)— Invalidate children queryinvalidateItem(path)— Invalidate item queryinvalidatePathAndParent(path)— Invalidate item + parent childreninvalidateSubtree(path)— Invalidate all queries containing path prefixinvalidateAllVfs()— Nuclear: invalidate all VFS queriesinvalidateTypes(scopeId?)— Invalidate type definitions
Realtime
initVfsRealtimeWs(scopeId)— Subscribe to VFS changes over WebSocketdisposeVfsRealtimeWs()— Tear down realtime connectionmountRealtimeScope(path)/unmountRealtimeScope(path)— Add/remove a scope from the realtime subscriptionsubscribeToPath(path, callback)— Subscribe to an item path and its descendantswaitForPathReady(path, timeoutMs?)— Wait for the gateway to acknowledge the current path subscriptionresubscribePath(path)— Send a fresh wire-level subscription for an active pathgetPathSubscriptionState(path)— Readpending,ready,disconnected, orunsubscribedonConnectionStateChange(callback)— Observe transport connection state changes
React Hooks (TanStack Query)
Read hooks:
| Hook | Description |
|---|---|
useItem(path) | Single item by path |
useItemById(id) | Single item by UUID |
useList(path) / useChildren(path) | Children of a path (location) |
useItems(filter) | Items by type with filters (location; account-scoped, not frame-aware) |
useInfiniteItems(filter, { realtime? }) | Kernel-filtered infinite pages; optional event-driven refresh, cached items, load more and refetch. No polling. |
useIncrementalItems(filter, { shouldReadUpdate? }) | Live created-at feed: query pages once, point-read inserts and selected updates, reconcile after deletes/reconnect. No polling. |
useInfiniteChildren(path) | Infinite-scroll directory listing |
useTree(path, depth?) | Recursive tree |
useSearch(query, types?) | Keyword search |
useResolve(params) | Multi-signal search (exact + trigram + keyword + semantic) |
useResolvedItems(contextId, itemType) | Access resolution — what this actor can reach here (frame-bounded, pref-aware). Use for connectors, tools, any member-scoped type. |
useOpen(path, options?) | Open envelope — render directives + compatible components |
useEdges(itemId) | Edges for an item |
useTypes(scopeId?) / useType(typeKey, scopeId?) | Type definitions |
useTypeHelpers(scopeId?) | Derived type-system helper functions |
useMemberFocus(scopeId, types?, limit?) | Items relevant to the current user in a scope |
useItemVersions(itemId, opts?) | Version history (useItemHistory is deprecated) |
useRecentActivity(scopePath, limit?, offset?) | Audit log for a scope |
useScope() / useScopeReady() | Current scope session / readiness |
useAuth() | Reactive Supabase auth |
useAccounts() | Account memberships, switcher accounts, and the accounts mounted under each |
usePrincipal() / useUpdatePrincipal() | Current principal identity / update |
useAsset(idOrPath) | Resolve an asset URL |
useComponent(path) | Dynamically load a component from the edge |
useTheme() | Read theme CSS custom properties |
useInfiniteItems(filter, { realtime: true }) uses the existing WebSocket event
dispatch and reconnect recovery. As with the other realtime hooks, the host
must hold an SDK account or path subscription (directly or via SDK bootstrap).
It does not open a separate socket or subscribe once per component. The shared
query cache stays fresh until a relevant item change or transport reconnect.
Events with a different direct parent_id do not refresh a parent-filtered list;
loaded items moving out still refresh their former list. Closed lists are marked
stale without fetching, and unchanged lists reopen from cache. All consumers of
the same filtered query should use the same realtime setting.
For a high-traffic message feed, useIncrementalItems avoids reloading every
loaded page whenever a reply changes. It supports one item type, an explicit
scope, sort_by: 'created_at', and optional parent_id, fields, or
field_in. It returns the same list and pagination fields as
useInfiniteItems. Inserts cost one item read; updates are ignored unless
shouldReadUpdate(change, loadedItems) selects them; moves, deletes, and reconnects
reconcile the pages. More complex filters fall back to the ordinary realtime
listing. The host still needs its normal account or path WebSocket subscription.
A filter names one type, or any_of: 1-10 groups, each with an item_type,
fields and field_in, so one listing spans item types with one total and one
page order. discovery: 'catalog' keeps only what the kernel classes as a
person's own standalone things; the kernel applies it before paging. A menu's
stored sections are run without building a filter by hand:
const { resolvedChildren } = useArrangement({ key: 'library' });
const section = resolvedChildren.find(child => child.child_type === 'query_filter');
const list = useInfiniteItems(
itemsFilterFromQueryFilter(section?.type_data, { scope: accountPath, limit: 50 }),
{ realtime: true },
);
// list.allItems, list.fetchNextPage(), list.isExhausted, list.totalHint
A relevant burst refreshes the loaded pages once, using the existing
refreshQuery protection against overlapping loads. Multiple pages are read
again together so offset pagination stays consistent after inserts/deletes.
Current VFS notifications do not include arbitrary type_data fields, so a
direct-child change may still require a listing read to evaluate a field filter
(for example role=user). Missing old-parent information can only identify a
move out when the changed item is already cached; reconnect or the next fetch
reconciles the remaining count. No background polling is used.
Channel / notification hooks:
| Hook | Description |
|---|---|
useChannelMessages(opts) | Channel messages + bundled cards |
useInfiniteChannelMessages(opts) | Cursor-paginated messages (live tail) |
useAllChannels() / useChannels() / useDmChannels() | Channel lists (all / public / DM); archived channels only with { includeArchived: true } |
usePersonalItemOrder('channel-order' | 'project-order', items) | Apply and save the selected Space member's personal order through existing arrangements. |
useNotifications(opts?) | Notifications feed |
useUnreadCounts(opts?) | Aggregated unread counts |
Realtime hooks:
| Hook | Description |
|---|---|
useEventStream(...) | Subscribe to SSE events by type |
useRealtimeQuery(opts) | useQuery that auto-refetches on matching events |
Mutation hooks:
| Hook | Description |
|---|---|
useCreate() | Create item (invalidates parent children) |
useUpdate() | Update item (invalidates item + parent) |
useDelete() | Delete item (removes from cache + invalidates parent) |
useMove() | Move item (invalidates old parent, new parent, item) |
useLink() | Create edge (invalidates edges for both endpoints) |
useMutation(method, opts?) | Generic mutation for any gateway method |
Source Files
src/
types.ts — All type definitions (VFSItem, TypeDefinition, VFSEdge, etc.)
config.ts — Reads __FSAOS_CONFIG__, creates Supabase client
enforcement.ts — EnforcementDeniedError class
session.ts — Auth token lifecycle, domain session init, scope accessors
client.ts — gatewayCall() — the core RPC dispatcher
query-client.ts — TanStack QueryClient with VFS-tuned defaults
vfs-keys.ts — Query key factory
vfs.ts — VFS fetch functions + cache invalidation helpers
vfs-realtime-ws.ts — WebSocket realtime subscriptions
ws.ts — WebSocket transport (events, scope, ping)
hooks.ts — All React hooks (useQuery/useMutation wrappers)
resolved-items.ts — useResolvedItems / fetchResolvedItems (access resolution)
upload.ts — File upload (imperative + hook)
schema-utils.ts — Schema interpretation utilities
index.ts — Barrel export
Application bootstrap
Set the registered application configuration before importing the SDK, then
initialize it once. Tracking starts by default; custom business events remain
explicit. Automatic page and declared-action capture runs for every published
source that declares those adapters. A successful public form submission sends
its receipt to each source that declares form_receipt; the default source
continues to supply the durable form row's visitor and session attribution.
window.__FSAOS_CONFIG__ = {
hostname: 'app.example.com',
gatewayUrl: 'https://fsaos-mcp-gw-rust.fly.dev',
};
const { initializeFsaos } = await import('@ai4b-team/fsaos-gateway-sdk');
const fsaos = await initializeFsaos();
Use initializeFsaos({ tracking: false }) when consent or product policy
requires tracking to remain disabled. Published FSAOS IIFE pages call the same
bootstrap automatically.
Build
npm install
node build.mjs # dist/gateway.js (minified IIFE, ~51 KB)
node build.mjs --dev # dist/gateway.dev.js (readable, ~128 KB)
The build produces:
dist/gateway.js— Minified IIFE assigned towindow.__FSAOS_GATEWAY__dist/gateway.dev.js— Unminified for debuggingdist/*.d.ts— TypeScript declarationsdist/*.js.map— Source maps
Dependencies
Bundled inline (part of the IIFE):
@tanstack/react-queryv5 — Query/mutation cache, deduplication, background refetch
External globals (loaded via CDN <script> before this bundle):
react— viawindow.React@supabase/supabase-js— viawindow.supabase.createClient
Zero Kernel
This SDK has zero references to "kernel" in source, bundle, or type declarations. All naming uses gateway terminology. (The kernel owns resolution, frames, and enforcement; the SDK is the control surface that forwards to it.)
License
Proprietary and source-available — see LICENSE. You may install and use this package as a dependency in your own applications, but you may not copy, modify, or redistribute it as a standalone library. All rights reserved by AI4B Team.
Personal channel and project ordering
usePersonalItemOrder(key, items) reuses resolve-arrangement and the ordinary
update primitive. Supply a complete collection of canonical IDs; render the
returned items and call reorder(ids) once at drop. It returns contextKey
for resetting in-progress UI gestures when the Space changes, plus ready,
supported, isLoading, isSaving, error, saveError and refetch.
The hook reads only the selected Space member's provisioned channel-order or
project-order arrangement. Account mounts are unsupported and make no requests.
It never creates a missing arrangement or falls back to a shared blueprint.
Channels without a saved position appear before manually ordered channels,
newest creation first using Channel.createdAt from the existing query payload.
Missing/equal creation times retain source order. With no saved order, this is
also the channel list's default order. Existing manually placed channels keep
their relative positions. Arrival/removal performs no arrangement write; the
next explicit reorder saves the supplied order. Project ordering is unchanged.
A save preserves unseen IDs, rejects stale Space callbacks and invalid lists,
and reconciles through the existing coalesced arrangement refresh. No polling,
item reads or parent changes are involved. For paged collections, apply ordering
in the backend before pagination; this hook does not discover Project folders
or provide that paged read contract.
Personal sidebar Sections
usePersonalSections(contextId, 'channels' | 'chats' | 'projects') reads personal
reference groups. Use a Space ID for channels/projects or a channel ID for chats.
The hook returns sections, ready, change, destination, and loading/error
state. change supports create, rename, delete, assign, remove and reorder
operations; it preserves canonical item locations. Supply a stable client UUID
for section_id on create so a retry cannot create a duplicate Section.
Capture destination(sectionId) before starting a new channel or chat, then call
assignPersonalSection(destination, savedItemId) once the canonical item has
been saved. Retry that filing separately from creation/sending. The captured
Space and context isolate late results after navigation. Versioned snapshots
prevent late responses from replacing newer organization; refresh uses existing
coalesced item-change events and reconnects, with no polling.
Requires the personal-sections kernel method from migration 0793523446ad.
Projects are protocol-ready; a consumer must supply its Project UI separately.
Bound browser read traffic
Hosts sharing the SDK's QueryClient can opt into transport scheduling before mounting readers:
configureGatewayReads({ concurrency: 6, timeoutMs: 15_000, onTiming: recordTiming });
useInfiniteItems(filter, { priority: 'foreground' });
gatewayCall('query', params, { signal, priority: 'background' });
Equivalent in-flight reads share one transport request, scoped by credentials,
session generation, hostname, and effective parameters. Each observer receives its
own result object and may cancel independently. The last cancellation aborts the
fetch; identity reset cancels queued and active reads. The deadline includes queue
time. onTiming reports only method, priority, queue/request duration and outcome.
Commands, writes, materializing queries and subscriptions bypass the queue. The
scheduler never retries a request; retry/cooldown policy remains with the host's
existing QueryClient. Without configuration, transport behavior is unchanged.