Files
iios/docs/IIOS_API_AND_SDK_GUIDE.md
maaz519 6fc03fa4c3 docs(iios): notifications — API guide §5.11 (endpoints + engine diagram), env, counts
- API guide §5.11 Notifications: vapid-public-key / subscribe / unsubscribe / thread
  mute endpoints, the focus_thread presence signal, the three gates, and the engine
  flow diagram; SDK reference (registerPush/muteThread/focus); VAPID env; tests 205.
- DEPLOYMENT: VAPID env + the in-memory-presence → Redis (multi-replica) caveat.
- CEO overview: counts (205 tests, 54 tables / 20 migrations) + notifications in the
  "P9 has begun" note.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 19:45:11 +05:30

28 KiB

IIOS — API & SDK Guide (for QA and Developers)

Everything you need to run, test, and build against the IIOS service and SDKs. One NestJS API (iios-service) + a set of browser SDKs. This doc is generated from the current code — endpoints, request/response shapes, SDK methods, auth, and env are all as-built.


1. Quick facts

Thing Value
Base URL (dev) http://localhost:3200 (env PORT, default 3200)
Auth Authorization: Bearer <JWT> — HS256, per-app secret
Content type application/json
Validation Global — unknown body fields are rejected (400); types are coerced
CORS Enabled (origin: true)
Realtime Socket.IO namespace /message
DB PostgreSQL (dev: localhost:5434, db iios)

Everything is scoped to a tenant. A caller can only see/act on data in its own scope (orgId + appId + optional tenantId, derived from the token). Cross-tenant access returns 403.


2. Getting started (run it locally)

# 1. Postgres (docker-compose in the repo root brings up 'iios-db' on :5434)
docker compose up -d

# 2. Install + generate + migrate
pnpm install
pnpm -F @insignia/iios-service prisma:generate
pnpm -F @insignia/iios-service prisma:migrate

# 3. Run the API (dev tokens ON so you can mint test tokens)
cd packages/iios-service && IIOS_DEV_TOKENS=1 pnpm start
# → iios-service listening on :3200

Health check: GET http://localhost:3200/health{ "status": "ok", "db": true, "egressProviders": { ... } }


3. Auth model (how to get a token)

Tokens are per-app JWTs signed with a secret from the APP_SECRETS env map ({"portal-demo":"dev-secret"} by default). The token carries sub (userId), appId, orgId, optional tenantId.

Get a dev token (only when IIOS_DEV_TOKENS=1):

curl -s -X POST http://localhost:3200/v1/dev/token \
  -H 'content-type: application/json' \
  -d '{"appId":"portal-demo","userId":"alice","orgId":"org_A"}'
# → { "token": "eyJhbGc..." }

Use it on every call: -H "authorization: Bearer <token>".

  • Different orgId = different tenant. Mint two tokens with org_A / org_B to test isolation.
  • Token TTL: 2h.

Real IdP tokens (production path). Beyond the dev HS256 tokens, SessionVerifier also verifies real OIDC access tokens (ES256) against a trusted issuer's public JWKS — set SUPABASE_URL (single issuer) or AUTH_ISSUERS (a registry mapping many issuers → app scopes). The verifier routes a token by its iss claim, so two projects/IdPs map to two isolated appId scopes on one service, and app A's tokens can't reach app B. No shared secret needed. The dev token path stays for tests/local.

Error responses (what QA will see)

Status When
400 Missing Authorization header, unknown appId, or invalid/unknown body fields
401 Invalid/expired token, or bad webhook signature
403 Cross-tenant access (resource not in your scope)
404 Resource id not found (within your scope)
201 Created (POST) · 200 others

4. Conventions

  • Idempotency — native message send accepts an idempotency-key header; adapter send takes idempotencyKey in the body; AI/route/meeting derive their own keys. Re-sending the same key returns the same record (no duplicate).
  • Scopes/tenancy — inferred from the token; you never pass scopeId in requests (except the raw /v1/interactions/ingest and adapter ingest which carry a full scope object).
  • Fail-closed — reads/writes/sends pass through policy (OPA) + consent (CMP) gates; a denial blocks the action rather than silently allowing it.
  • Preview / propose first — routing simulates before sending; AI proposes (never auto-decides); meetings gate recording on consent.

5. REST API reference

Grouped by domain. All paths are relative to the base URL. Auth = Bearer token required unless noted.

5.1 Health & Dev

Method Path Auth Body / Query Returns
GET /health none {status, db, egressProviders}
POST /v1/dev/token none* {appId, userId, name?, orgId?} {token}
POST /v1/dev/webhook/:channelType none* {from?, text?} raw event (simulated signed inbound)

*Dev endpoints require IIOS_DEV_TOKENS=1; never enabled in prod.

5.2 Interactions (kernel ingest)

Method Path Body Returns
POST /v1/interactions/ingest IngestRequestDto {interactionId, ...}

IngestRequestDto: { scope:{orgId,appId,buId?,tenantId?,workspaceId?,projectId?,userScopeId?}, channel:{type,externalChannelId?,capabilityContract?}, source:{handleKind,externalId,displayName?}, kind?, thread?:{externalThreadId?,subject?}, parts:[{kind,bodyText?,contentRef?,mimeType?}], occurredAt (ISO8601), providerEventId?, metadata? }. Send providerEventId twice → deduped.

5.3 Threads & Messaging (native chat)

Method Path Body / Headers Returns
GET /v1/threads ThreadSummary[] — your threads (members, unread, last message, membership)
POST /v1/threads {membership?, creatorRole?, subject?} {threadId, status, history} (201) — membership/creatorRole/subject are opaque app attributes the kernel stores but never interprets
POST /v1/threads/:id/participants {userId, role?} {threadId, participantCount} (201) — governed (DM cap / group-admin via OPA)
GET /v1/threads/:id/messages {threadId, messages[]}
POST /v1/threads/:id/messages {content, contentRef?, mimeType?, sizeBytes?, checksumSha256?, parentInteractionId?} + idempotency-key? header Message (201)
GET /v1/threads/my-annotations ?type=save {message, threadId, threadSubject}[] — messages you annotated (e.g. saved)

A Message carries {id, threadId, senderId, senderName, content, attachment?, parentInteractionId?, annotations[], traceId, createdAt}. senderId is the sender's stable externalId (email/username) — the reliable "is this mine?" check. attachment = {contentRef, mimeType, sizeBytes, kind} (§5.10). annotations = [{type, value, users[]}] — the reaction/pin/save aggregate.

Realtime (Socket.IO, namespace /message). Token verified on connect (auth.token), then emit client→server:

  • open_thread {threadId?, membership?, creatorRole?, subject?} — opens, or creates when no id; a governed join for membership threads. Returns {threadId, status, history} or {error} (acked, never a throw — clients don't hang).
  • send_message {threadId, content, contentRef?, mimeType?, sizeBytes?, parentInteractionId?, mentions?}mentions is an opaque userId notify-list (the app parses @; the kernel never does).
  • add_participant {threadId, userId, role?} · read {threadId, interactionId} · delivered {…} · typing {threadId}.
  • annotate {threadId, interactionId, type, value} — toggle a generic annotation (chat app uses type: reaction|pin|save; value = emoji, or empty).

Server → thread room: message (a Message), receipt {interactionId, actorId, kind: READ|DELIVERED}, typing {threadId, userId}, annotation {interactionId, type, value, op: add|remove, users[], userId}.

Governance & primitives (all fail-closed via OPA). Membership (iios.thread.participant.add, iios.thread.join), annotating (iios.interaction.annotate — participant-only), and send all gate on policy. Reactions/pins/saves are one generic primitive — an interaction annotation the kernel stores + aggregates as opaque (type, value) but never interprets (the same primitive backs pins/saves/flags/tags). Mentions → Inbox: mentions[] flows into the message event; the inbox projector fans out a MENTION inbox item to each mentioned participant (never the sender); reading the thread resolves it.

5.4 Inbox (work queue)

Method Path Query / Body Returns
GET /v1/inbox/items `?state=OPEN SNOOZED
PATCH /v1/inbox/items/:id {state, reason?} InboxItem

Item kinds include NEEDS_REPLY (unreplied thread activity, one per owner+thread) and MENTION (someone @-mentioned you; priority: HIGH, one per source message). Reading the thread resolves both to DONE.

5.5 Support (tickets, escalation, callbacks, queues, agents)

Method Path Body / Query Returns
POST /v1/support/tickets {subject, priority?, threadId?} Ticket
POST /v1/support/escalate {threadId, subject?} Ticket (opens customer↔agent thread)
GET /v1/support/tickets `?scope=mine assigned`
PATCH /v1/support/tickets/:id {state, reason?} Ticket
POST /v1/support/callbacks {preferChannel, preferredTime?, notes?, ticketId?} CallbackRequest
POST /v1/support/queues {name} {id}
POST /v1/support/queues/:id/members membership
POST /v1/support/agents/me/join joins default queue (go online)
PATCH /v1/support/members/me/availability {state} membership

priority ∈ {LOW, NORMAL, HIGH, URGENT}; ticket state ∈ {NEW, OPEN, PENDING, RESOLVED, CLOSED} (transitions validated).

5.6 Adapters (channels — inbound/outbound)

Method Path Auth Body / Headers Returns
POST /v1/adapters/:channelType/webhook signature raw provider body + x-iios-signature (HMAC) 202 (async normalize)
POST /v1/adapters/:channelType/send Bearer {target, payload?, idempotencyKey?} OutboundCommand
GET /v1/adapters/inbound Bearer raw inbound events (+ interactionId once normalized)
GET /v1/adapters/outbound Bearer outbound commands (+ delivery attempts)

channelType ∈ {WEBHOOK, EMAIL, WHATSAPP, PORTAL}. Outbound goes through the Capability Broker (policy + consent + provider); a blocked send → command status BLOCKED, a tenant over quota → RATE_LIMITED.

5.7 Routing / rules (community)

Method Path Body / Query Returns
POST /v1/routes/bindings CreateBindingDto RouteBinding
GET /v1/routes/bindings RouteBinding[] (your scope)
POST /v1/routes/simulate {interactionId, originChannelType, originRef?} {simulationId, decisions[]} (no send)
GET /v1/routes/decisions `?state=ALLOW DENY
POST /v1/routes/decisions/:id/approve RouteDecision (executes to sandbox)
POST /v1/routes/decisions/:id/deny RouteDecision
POST /v1/routes/bindings/:id/flush-digest {forwarded}

CreateBindingDto: {originChannelType, originRef?, destinationChannelType, destinationRef?, restrictionProfile?, mode?, outputFormat?, frequency?, includeAttachments?, requiresReview?, enabled?}. mode ∈ {MANUAL, AUTOMATIC, HYBRID, SIMULATION_ONLY}; outputFormat ∈ {FORWARD, THREADED, DIGEST, SUMMARY, TRANSCRIPT}. Restricted destinations are deny-by-default for flagged content.

5.8 AI (proposal layer)

Method Path Body / Query Returns
POST /v1/ai/jobs {interactionId, jobType} {job, artifact}
GET /v1/ai/artifacts ?interactionId=&status= AiArtifact[] (your scope)
GET /v1/ai/artifacts/:id AiArtifact (claims + evidence + modelRun)
POST /v1/ai/artifacts/:id/accept AiArtifact (ACCEPTED)
POST /v1/ai/artifacts/:id/reject AiArtifact (REJECTED)

jobType ∈ {CLASSIFY, SUMMARIZE, EXTRACT}. Artifacts carry confidence, evidence (citations), and abstentionReason when the model declines. AI proposes; a human accepts.

5.9 Calendar & Meetings

Method Path Body / Query Returns
POST /v1/calendar/meetings ScheduleMeetingDto Meeting (SCHEDULED)
POST /v1/calendar/requests RequestMeetingDto + ?fromCallback=&fromClaim= meeting request
GET /v1/calendar/meetings Meeting[] (your scope)
GET /v1/calendar/meetings/:id Meeting (participants, transcripts, summaries, action items)
POST /v1/calendar/meetings/:id/consent {actorRefId, status} participant
POST /v1/calendar/meetings/:id/transcript transcript (READY or BLOCKED)
POST /v1/calendar/meetings/:id/summarize Meeting (summary + action items)
GET /v1/calendar/meetings/:id/action-items MeetingActionItem[]
POST /v1/calendar/providers provider account (simulated)
POST /v1/calendar/providers/:id/sync {pulled, cursor}

ScheduleMeetingDto: {meetingType, title, startAt, endAt?, timezone?, attendees?:[{userId, displayName?, role?, visibility?}], requestId?}. meetingType ∈ {ZOOM, PHONE, IN_PERSON, CALLBACK, INTERNAL}; consent status ∈ {UNKNOWN, GRANTED, DENIED, REVOKED}. Transcript/summary is BLOCKED until every attendee has GRANTED consent.

5.10 Media (attachments)

The DB stores a reference; the bytes live behind a storage port (dev = local disk MEDIA_DIR; prod = swap to S3/R2/Supabase). Bytes go client ↔ storage directly via short-lived signed URLs — they never pass through the kernel.

Method Path Auth Body Returns
POST /v1/media/presign-upload Bearer {mime, sizeBytes} {objectKey, uploadUrl}OPA-gated (size/type)
PUT /v1/media/upload/:token token in URL raw bytes {objectKey, sizeBytes, checksumSha256}
POST /v1/media/presign-download Bearer {contentRef, mime?} {url}tenant-fenced, signed 1h
GET /v1/media/blob/:token token in URL streams the bytes with their Content-Type

Attaching to a message: send/send_message accept {contentRef, mimeType, sizeBytes, checksumSha256?}. The stored Message then carries attachment: { contentRef, mimeType, sizeBytes, kind } where kind ∈ {image, video, audio, file} (a friendly view of the generic MEDIA_REF/VOICE_REF/FILE_REF part the kernel writes).

Governance (fail-closed, built in):

  • Upload policy iios.media.upload≤ 25 MB and an allowlist (image/*, video/*, audio/*, application/pdf, common Office/text/zip). A violation → 403 before any bytes are sent.
  • Signed tokens (HS256, MEDIA_SECRET) — upload URL lives 5 min, download URL 1 h; a tampered/expired token → 403.
  • Tenant fence — object keys are prefixed with the caller's scopeId; a download for an object outside your scope → 403.

The flow (with governance):

sequenceDiagram
  autonumber
  participant C as Client (SDK uploadMedia)
  participant API as IIOS Media API
  participant OPA as OPA policy
  participant ST as StoragePort (disk → S3)
  participant K as Message kernel

  C->>API: POST /v1/media/presign-upload {mime, sizeBytes}
  API->>OPA: decide iios.media.upload (≤25MB? type allowed?)
  alt denied
    OPA-->>C: 403 (too large / type not allowed)
  else allowed
    API-->>C: { objectKey, uploadUrl }  (signed, 5-min)
    C->>ST: PUT bytes → uploadUrl  (direct, not via kernel)
    ST-->>C: { sizeBytes, checksumSha256 }
    C->>K: send_message { contentRef, mime, size }
    K-->>C: Message { attachment }
    C->>API: POST /v1/media/presign-download { contentRef }
    API->>API: tenant-fence (objectKey in my scope?)
    API-->>C: { url }  (signed, 1-hour)
    C->>ST: GET url → bytes (stream, inline render / download)
  end

5.11 Notifications (push, presence-gated)

The engine reacts to message.sent and pushes to absent recipients through a swappable NotificationPort (Web Push/VAPID now; email/FCM later). The DB holds only push subscriptions + a per-thread mute flag; the reusable "who has an unread" feed stays IiosInboxItem.

Method Path Body Returns
GET /v1/notifications/vapid-public-key {key} — the public VAPID key the client needs to subscribe
POST /v1/notifications/subscribe {kind?, endpoint, keys:{p256dh, auth}, userAgent?} {ok:true} — store/refresh the caller's push subscription
DELETE /v1/notifications/subscribe {endpoint} {ok:true}
POST /v1/threads/:id/mute · /unmute {threadId, muted} — per-thread notification mute for the caller

Presence (socket): the app emits focus_thread {threadId | null} on the /message namespace whenever the foreground conversation changes (or the tab blurs). This is how the engine knows you're viewing a thread — room membership ≠ viewing, because the sidebar joins every thread room for live updates. GET /v1/threads returns each thread's muted.

The three gates (fail-closed, in the notification policy — not the kernel):

  1. Policy — DM always; a group message only if you were @-mentioned or it's a reply to you.
  2. Presence — skip if you're currently focused on that thread (you saw it live).
  3. Mute — skip if you muted the thread. A dead subscription (Web Push 404/410) is pruned. DM-vs-group is read from the opaque membership thread attribute here in the notification policy; the kernel never branches on it.
flowchart TB
  SENT["message.sent (outbox → bus)"] --> PROJ["NotificationProjector"]
  PROJ --> LOOP{{"for each recipient (never the sender)"}}
  LOOP --> G1{"POLICY: DM? · @mention? · reply-to-you?"}
  G1 -->|no| X1["skip"]
  G1 -->|yes| G2{"PRESENCE: focused on this thread?"}
  G2 -->|yes| X2["skip — seen live"]
  G2 -->|no| G3{"MUTE: thread muted?"}
  G3 -->|yes| X3["skip"]
  G3 -->|no| SUBS["load subscriptions"] --> PORT["NotificationPort.deliver()"]
  PORT --> WP["Web Push (VAPID)"]
  WP -->|sent| OUT["→ browser push service → service worker → OS notification"]
  WP -->|"gone (404/410)"| PRUNE["prune dead subscription"]

Client (SDK layer, lib/notifications.ts → belongs in @insignia/iios-kernel-client): registerPush() (permission → register service worker → PushManager.subscribe with the VAPID key → POST the subscription), muteThread(threadId, muted), and MessageSocket.focus(threadId). A service worker renders the OS notification on push and deep-links to the thread on click.


6. SDK reference

Two layers: a low-level RestClient (+ socket) and per-domain React hook packages.

6.1 Low-level client — @insignia/iios-kernel-client

import { RestClient } from '@insignia/iios-kernel-client';
const client = new RestClient({ serviceUrl: 'http://localhost:3200', token });

Methods (all return typed promises):

  • Messaging: listThreads(), createThread({membership?, creatorRole?, subject?}), addParticipant(threadId, userId, role?), getThreadMessages(threadId), sendMessage(threadId, content, {attachment?, parentInteractionId?, mentions?, idempotencyKey?})
  • Reactions / pins / saves (annotations): MessageSocket.react(threadId, interactionId, emoji) · pin(...) · save(...) (generic annotate under the hood); listMyAnnotated('save') for a cross-thread saved list. Subscribe to the annotation event for live updates.
  • Media: uploadMedia(file, {onProgress?}) → {contentRef, mimeType, sizeBytes, checksumSha256, kind} (presign → PUT-with-progress → normalized ref); mediaUrl(contentRef) → signed view URL (cached, short-lived). This plumbing is identical for every app, so it lives in the SDK; the app only renders by kind.
  • Notifications: registerPush() (service worker + PushManager.subscribe + POST subscription), muteThread(threadId, muted), MessageSocket.focus(threadId) (presence signal). The engine (projector + Web Push port) is server-side; the SDK/app own registration + rendering.
  • Inbox: listInboxItems(state?), patchInboxItem(id, {state, reason?})
  • Support: createTicket({subject, priority?, threadId?}), escalate(threadId, subject?), listTickets('mine'|'assigned'), patchTicket(id, state), requestCallback({...}), createQueue(name), joinQueue(id), joinDefaultQueue(), setAvailability(state)
  • Routing: createBinding(input), listBindings(), simulateRoute({interactionId, originChannelType, originRef?}), listRouteDecisions(state?), approveDecision(id), denyDecision(id)
  • AI: runAiJob({interactionId, jobType}), listArtifacts({interactionId?, status?}), getArtifact(id), acceptArtifact(id), rejectArtifact(id)
  • Calendar: scheduleMeeting(input), requestMeeting(input, {fromCallback?, fromClaim?}), listMeetings(), getMeeting(id), setConsent(meetingId, actorRefId, status), generateTranscript(meetingId), summarizeMeeting(meetingId), listActionItems(meetingId), connectCalendarProvider(), syncCalendarProvider(id)
  • Ingest: ingest(body, idempotencyKey)
  • Realtime: MessageSocket (Socket.IO facade for /message).

6.2 React hook packages (front-end teams)

Each package exports a Provider (wrap your app with serviceUrl + token) plus hooks:

Package Provider Hooks
@insignia/iios-message-web MessageProvider useThread, useMessages
@insignia/iios-inbox-web InboxProvider useInbox
@insignia/iios-support-web SupportProvider useEscalate, useTickets, useAssignedTickets, useCallbackRequest, useAvailability, useGoOnline (+ re-exports useThread/useMessages)
@insignia/iios-community-web CommunityProvider useBindings, useRoutePreview, useRouteDecisions
@insignia/iios-ai-web AiProvider useRunJob, useAiProposals, useArtifact
@insignia/iios-meeting-web MeetingProvider useMeetings, useMeeting, useSchedule, useConsent, useSummarize, useActionItems
// Example: an AI-assist panel
import { AiProvider, useRunJob, useAiProposals } from '@insignia/iios-ai-web';

<AiProvider serviceUrl={SERVICE} token={token}>
  <Panel interactionId={id} />
</AiProvider>;

function Panel({ interactionId }: { interactionId: string }) {
  const run = useRunJob();
  const { artifacts, accept, reject } = useAiProposals({ interactionId, pollMs: 2500 });
  return <>
    <button onClick={() => run(interactionId, 'SUMMARIZE')}>Summarize</button>
    {artifacts.map(a => <ProposalCard key={a.id} a={a} onAccept={() => accept(a.id)} onReject={() => reject(a.id)} />)}
  </>;
}

7. QA quickstart (curl walkthrough)

BASE=http://localhost:3200
TOKEN=$(curl -s -X POST $BASE/v1/dev/token -H 'content-type: application/json' \
  -d '{"appId":"portal-demo","userId":"alice","orgId":"org_A"}' | jq -r .token)
AUTH="authorization: Bearer $TOKEN"

# schedule a meeting
MID=$(curl -s -X POST $BASE/v1/calendar/meetings -H "$AUTH" -H 'content-type: application/json' \
  -d '{"meetingType":"INTERNAL","title":"QA test","startAt":"2026-07-02T10:00:00.000Z"}' | jq -r .id)

# transcript is BLOCKED (no consent yet)
curl -s -X POST $BASE/v1/calendar/meetings/$MID/transcript -H "$AUTH" | jq .status   # "BLOCKED"

# tenant isolation: a second tenant cannot read it → 403
TOKEN_B=$(curl -s -X POST $BASE/v1/dev/token -H 'content-type: application/json' \
  -d '{"appId":"portal-demo","userId":"bob","orgId":"org_B"}' | jq -r .token)
curl -s -o /dev/null -w "%{http_code}\n" $BASE/v1/calendar/meetings/$MID \
  -H "authorization: Bearer $TOKEN_B"   # 403

Ready-made end-to-end tests (smoke scripts)

Run against a live service (from packages/iios-service, node scripts/<name>):

Script Proves
smoke-realtime.mjs native chat + realtime + unread (P2)
smoke-inbox.mjs needs-reply queue + auto-resolve (P3)
smoke-support.mjs (+ seed-support.mjs) ticket assign + escalate + live reply (P4)
smoke-adapter.mjs signed webhook ingest + sandbox send (P5)
smoke-route.mjs preview-first routing, deny-by-default, approve→send (P6)
smoke-ai.mjs AI classify/summarize/extract, abstain, KG-05 (P7)
smoke-calendar.mjs consent-gated transcript, summary, action items (P8)
smoke-capability.mjs governed egress + real HTTP provider (needs IIOS_PROVIDER_URL_EMAIL)
smoke-tenant.mjs cross-tenant 403 + list isolation

Automated unit/integration suite: pnpm test (205 tests). Import-boundary check: pnpm boundary.


8. Tenancy & security notes for QA

  • Cross-tenant — any by-id read/mutate of another tenant's resource → 403; lists only ever return your own scope's data. Test with two orgIds.
  • Consent — a meeting transcript/summary is BLOCKED unless every attendee consented; an outbound send to an opted-out recipient is BLOCKED (consent gate).
  • Per-tenant quota — one tenant flooding outbound gets RATE_LIMITED without affecting others.
  • AI safety — AI outputs are proposals (PROPOSED) with confidence/evidence; they never auto-send or auto-approve; low-confidence → abstains.
  • Idempotency — replaying a webhook/message/job with the same key yields one record.

9. Environment variables

Var Default Purpose
PORT 3200 HTTP port
DATABASE_URL postgresql://iios:iios@localhost:5434/iios?schema=public Postgres
APP_SECRETS {"portal-demo":"dev-secret"} per-app HS256 JWT secrets ({appId: secret})
SUPABASE_URL / AUTH_ISSUERS trusted OIDC issuer(s) — verify real IdP tokens (ES256) via JWKS. AUTH_ISSUERS is a JSON array [{url, appId, orgId?}] mapping issuers → app scopes; SUPABASE_URL is the single-issuer shorthand
MEDIA_DIR <tmp>/iios-media local media storage dir (dev StoragePort)
MEDIA_SECRET dev-media-secret signs media upload/download URLs
PUBLIC_URL http://localhost:$PORT base used to build presigned media URLs
VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY / VAPID_SUBJECT Web Push (notifications). Unset → push disabled (engine no-ops). Generate once: node -e "console.log(require('web-push').generateVAPIDKeys())"
IIOS_DEV_TOKENS 0 set 1 to enable /v1/dev/*
ADAPTER_SECRETS {} per-channel HMAC secrets (default dev-adapter-secret)
IIOS_OUTBOUND_LIMIT / _WINDOW_MS 5 / 60000 per-(channel,target) rate limit
IIOS_TENANT_OUTBOUND_LIMIT / _WINDOW_MS 10000 / 60000 per-tenant egress quota
IIOS_AI_BUDGET_UNITS 1000000 per-tenant AI cost budget
IIOS_CELL_ID cell-default cell assignment for new scopes
IIOS_PROVIDER_URL_<CHANNEL> set to route that channel's egress to a real HTTP provider
IIOS_RELAY_INTERVAL_MS 500 outbox → event relay poll interval

10. Vocabularies (enums)

  • Interaction kind: MESSAGE, EMAIL, SYSTEM_NOTICE, INBOX_WORK, SUPPORT_CASE, MEETING_REQUEST, DIGEST, SUMMARY, NOTIFICATION
  • Channel types: WEBHOOK, EMAIL, WHATSAPP, PORTAL
  • Inbox state: OPEN, SNOOZED, DONE, ARCHIVED, CANCELLED, STALE · Inbox kind: NEEDS_REPLY, NEEDS_REVIEW, NEEDS_APPROVAL, MENTION, SUPPORT_UPDATE, MEETING_FOLLOWUP, DIGEST, SYSTEM_ALERT, CRM_OWNER_INTEREST
  • Message part kind: TEXT, HTML, MARKDOWN, MEDIA_REF, FILE_REF, VOICE_REF, LOCATION, STRUCTURED_JSON · Attachment kind (DTO): image, video, audio, file
  • Interaction annotation (app-level, opaque to the kernel): type = reaction | pin | save … ; value = emoji (reactions) or empty
  • Ticket state: NEW, OPEN, PENDING, RESOLVED, CLOSED · priority: LOW, NORMAL, HIGH, URGENT
  • Route mode: MANUAL, AUTOMATIC, HYBRID, SIMULATION_ONLY · output format: FORWARD, THREADED, DIGEST, SUMMARY, TRANSCRIPT · decision: ALLOW, DENY, REVIEW, SUPPRESS, SIMULATED
  • AI job: CLASSIFY, SUMMARIZE, EXTRACT · artifact: CLASSIFICATION, SUMMARY, TRANSCRIPT, DIGEST, EXTRACTION · artifact status: PROPOSED, ACCEPTED, REJECTED, SUPERSEDED
  • Meeting type: ZOOM, PHONE, IN_PERSON, CALLBACK, INTERNAL · status: REQUESTED, SCHEDULED, CONFIRMED, CANCELLED, COMPLETED, NO_SHOW · consent: UNKNOWN, GRANTED, DENIED, REVOKED · participant visibility: FULL, LIMITED, NONE

Base URL, ports, and demos: the service is http://localhost:3200; runnable demo UIs live under apps/ (message-demo 5173, agent-demo 5174, adapter-inspector 5175, route-admin 5176, ai-studio 5177, meeting-studio 5178). For architecture/what-each-SDK-is-for, see docs/IIOS_OVERVIEW_FOR_CEO.md.