Prototype SDK

Iris SDK

A tiny runtime for a facilitation agent. Iris reads a group chat, keeps a sealed private read of every participant, and decides when—and how—to intervene. It never decides for the group.

Overview

The runtime is one class, IrisAgent, in iris-sdk.js — a collaboration agent. Configuring it with a facilitation policy and the decisions surface is what makes it “Iris”; swap those and the same class is a standup bot or a vote. It's UI-agnostic—the display layer only calls into it.

What a single-principal SDK can't do: it holds a group — a shared channel plus a private line to each participant, memory partitioned per person, and a de-identified boundary between them. That multi-principal substrate is the SDK; policy + surface are the configuration.

The loop, tools, and model are a generic agent — deliberately, we don't reinvent them. Everything that makes Iris Iris is the multi-principal substrate a single-principal SDK can't express:

 A generic agentIris — a collaboration agent
Principalone usera group — participants
Channelsone conversationa shared room + a private caucus(id) per participant
Memoryone pool it fully seesmemory.private[id] — sealed per participant
What it may sayanything it knowsscoped by context({ audience }) — can't leak what it can't see
Shared artifactnone (just the transcript)a co-owned surfaceroom.canvas

Quickstart

Construct the agent with a policy, the participants, a surface, the seed room, and a model. Configuring it with the facilitation policy + decisions surface is what makes it “Iris”. Then drive it: run(event) handles one turn.

// 1 — construct the agent
import { IrisAgent } from './iris-sdk.js';
import { openrouter } from './models.js';
import { decisions } from './surfaces.js';
import { PARTICIPANTS, ROOM } from './scenario-elrond.js';

const iris = new IrisAgent({
  policy: await (await fetch('./policy-facilitator.md')).text(),  // a default policy (or your own)
  participants: PARTICIPANTS,    // [{ id, name, role, read }] — read seeds a sealed file each
  surface: decisions,           // the co-owned artifact schema (default)
  room:    ROOM,                // seed { topic, items }
  model:   openrouter({ apiKey: MY_KEY, model: 'anthropic/claude-opus-4' }),
});                             // omit `model` → offline mocks

// 2 — drive it: a trigger fires one turn. Effects (post / caucus / canvas) are delivered;
//     run() returns the acts Iris took this turn.
const acts = await iris.run({ type: 'message', author: 'boromir', text: 'Why not use the Ring against him?' });

// read state directly for your UI
iris.room.items;                // items on the surface — the state (each is a Decision here); own heartbeat + read
iris.room.canvas;               // ↳ a de-identified projection of them, for the group
iris.caucus('boromir');         // a sealed 1:1 thread (= iris.memory.private.boromir.caucus)
Want to walk the beats one at a time (for a prev/next UI, or to inspect each model call)? That's a debug affordance, not the default — see Stepper.

The cycle

A trigger fires a turn. run(event) takes an event — { type:'message', author, text } today, a timer or webhook later — and handles one turn. Iris's outputs (a room post, a caucus DM, a Canvas change) are delivered as side effects and stream through onEvent; run returns the acts it took this turn.

beatTrigger / methodModel?What it does
human eventingest(author, text)noa line enters the room — appended to the log; this starts the turn
update memoryupdateMemory()yesopen / advance / close any surface items this line moves; re-derive their heartbeat + read
introspect & actdecide() + act()yesreason privately (decide() logs a first-person note to introspection), then act on it — post, caucus, or none (hold) — writing the message in the same beat

Introspection and action are one beat. decide() is the introspection half — it logs why she's about to act (or hold) to iris/introspection/log.md — and act() is the doing half. A decision without its message is half an action, so the debugger stops after the message is written, not between the reasoning and the words. A none choice is hold: she reasoned and chose to stay peripheral. maxSteps bounds actions per turn (the app sets 1, so one action ends the turn). decide picks a registered tool; the built-in defaults are post and caucus. Coaching and facilitating both use caucus; the policy stance shapes what gets written.

Memory & the collaboration primitives

The agent is built around a group, not one user. That's what a single-principal SDK can't express, and it's why memory is partitioned:

A surface item carries three layers: public shared fields (title, status, resolution), the agent's private notes (heartbeat, read), and sealed per-participant contributions. See Surfaces.

Canvas

iris.room.canvas is the surface items projected for the group, de-identified: the item's shared fields + an aggregate of the sealed contributions. Notes and raw contributions are not included. It's a live getter on room (the items are the source of truth). Render it as a Slack Canvas, a pinned summary, or a task list.

Confidentiality — context({ audience })

Confidentiality isn't a line in the prompt; it's a property of what each model call is allowed to see. One primitive, context({ audience }), assembles exactly that:

So when Iris composes a room post, the model never receives anyone's sealed read — it can't leak what it can't see. The acting tool's audience selects which one. (A poll's votes are sealed contributions; the board only ever shows the tally, so the boundary holds mechanically, not by prompt.)

Policy & presets

The policy is the system prompt (an AGENTS.md-style file). The coach/facilitator stance is written into it — there is no separate mode setting. Two presets ship:

PresetIris acts…Concretely
policy-facilitator.mdfor the groupcaucuses people herself, then posts a de-identified synthesis in her own voice
policy-coach.mdthrough a personprivately hands one named person the words; lets them speak; posts nothing herself

Both share the same facilitation core and differ only in a Your stance section. To switch stance, load the other policy:

iris.policy = await (await fetch('./policy-coach.md')).text();

Surfaces

The surface is the co-owned artifact the group builds — and it's what turns this from “Iris” into a substrate. Iris uses decisions; swap the surface (and policy) and the same runtime is a standup bot or a vote. A surface is a schema; the substrate does items, participation, projection, and confidentiality generically.

{
  name,
  shared:  ['title', 'options', 'status', 'resolution'],  // public fields
  notes:   ['heartbeat', 'read'],              // the agent's private working notes
  contribution: { stance: '' },              // a per-participant SEALED contribution
  statuses: ['gathering', 'groan', 'converging', 'decided'],
  fields: { options: { type: 'array', items: { … } } },   // per-field JSON Schema; string by default
  aggregate: (contribs) => ({ weighedIn: contribs.length }),  // sealed → de-identified public
}

An item's public projection is its shared fields + aggregate(contributions). notes and the raw contributions never cross to the room — that's where the surface meets the confidentiality boundary.

A shared field can be richer than a string. fields gives the model a JSON Schema per field (fields not listed default to a string; status to an enum of statuses) — and it's enforced at the model call. The decisions surface uses it for options: the courses of action on the table, each { label, by }. A proposal stated openly in the room is a public position, so it's attributed and shows on the Canvas — unlike the private read (the interest underneath), which stays de-identified. Positions are named; interests are not. Two surfaces ship in surfaces.js:

SurfaceItemsSealed per participantPublic projection
decisionsthings to decidea stance (presence)status + resolution + weighed-in count
polloptions to choosea votetallies only — a secret ballot

The poll case is the sharp one: individual votes are sealed contributions, so aggregate can only ever expose a tally. The runtime cannot surface who voted what — confidentiality falls out of the data model, not the prompt. Others follow the same shape: tasks (assignee/done), availability (“N of M free”), concerns (de-identified), terms (agreed clauses).

Tools

Iris's action space is a registry, not a fixed set. A tool is:

{ name, audience: 'room'|'person', description, run(agent, args) }

The defaults live in tools.jspost and caucus, provided like a hosted web_search and fully swappable. Pass your own via the tools option to extend the action space (react, nudge, escalate, …).

ToolAudienceWhat it does
postroomspeak to the whole room
caucuspersonprivate 1:1 DM to one person

audience is load-bearing: it selects the confidentiality tier the act may draw on. A room tool is composed with no sealed reads in context; a person tool sees only that person's file. Add a tool, and its audience decides what it can and can't see — confidentiality extends for free.

Models

A model is an injected function — the agent never sees keys or endpoints. Its signature:

type Model = (input: { system: string, prompt: string }) => Promise<string>;

Bring your own, or use a built-in adapter from models.js. Each is a small factory that closes over its config and returns that function — the same shape whether it's OpenRouter, Anthropic direct, or a local proxy.

import { openrouter, anthropic, proxy } from './models.js';

// OpenRouter — one key, any vendor. Route with a "vendor/model" id.
openrouter({ apiKey, model: 'anthropic/claude-opus-4' })   // or 'openai/gpt-5', 'google/gemini-2.5-pro'

// Anthropic, direct (browser-direct in the prototype)
anthropic({ apiKey, model: 'claude-opus-4-8' })

// Local proxy — key stays server-side (.env). Pair with serve.py.
proxy({ model: 'claude-opus-4-8' })

// no adapter → offline mocks (the scenario's mock* fns; still stance-aware)
new IrisAgent({ policy, participants, room });   // model omitted
AdapterConfigWhen
openrouter{ apiKey, model }one key across vendors; swap models by string
anthropic{ apiKey, model }Claude direct, no router in the middle
proxy{ url?, model }keep the key off the page (server reads .env)
(omit)offline demo; deterministic mocks
Not for production. The openrouter and anthropic adapters call the vendor from the browser, so the key is exposed to the page. Fine for a local prototype; use proxy (or your own backend) otherwise.

IrisAgent API

Constructor

new IrisAgent({ participants, policy, surface?, room, model, tools?, maxSteps?, onEvent?, store? })

participants is the group; surface defaults to decisions (see Surfaces). policy and model are plain fields — mutate them live (iris.model = anthropic({…})) and the change takes effect on the next beat, no rebuild. There is no mode field — the stance is just which policy you load. tools defaults to [post, caucus]; maxSteps (default 6) bounds actions per turn; onEvent(type, payload) streams memory / decide / act events; store is a reserved memory-I/O adapter.

Methods

MethodReturnsNotes
run(event)Promise<action[]>default drive — one turn per trigger; reflects, then loops the act beat to completion
ingest(author, text)message① sync; appends the line to the room
updateMemory()Promise<delta>② reflect — re-derives surface items + participant memory
decide()Promise<choice>③ act, first half — chooses a tool; also stored on iris.decision
act()Promise<action>③ act, second half — writes the message and calls the tool
caucus(id)message[]the 1:1 thread with a participant
addCaucusReply(id, text)a participant replies inside their caucus
context({ audience })stringthe assembled, audience-scoped context (the confidentiality primitive)
stepper(queue?)Stepperdebug harness (see below)

State & projections

iris.room (incl. room.items), iris.memory (.shared / .private[id]), iris.participants, iris.lastMessage, iris.decision, iris.isLive — plus iris.room.canvas, the de-identified projection of the surface items for the group. Read them directly to render a UI.

Stepper — debug drive

Run-to-completion is the default. When you want to advance one beat at a time — a prev/next UI, or inspecting each model call — ask the agent for a Stepper. Each next() is one observable transition: a line was said, Iris reflected, or Iris did one thing (decide + write together). It keeps a snapshot history so prev() is real undo.

const step = iris.stepper(MESSAGE_QUEUE);   // optional scripted queue

await step.next();       // ① a line enters the room (or pass { author, text } to inject)
await step.next();       // ② Iris reflects  → step.changed = Set of changed files
await step.next();       // ③ Iris acts: decide + write → step.decision, step.lastAction (repeats; ends on hold)
step.prev();             // undo — restores the previous snapshot
MemberNotes
next(inject?)advance one beat; at ① pulls from the queue or injects {author, text}. After the end, redoes forward through undone steps.
prev()step back one snapshot
phase / nextLabel'msg'|'reflect'|'act', and a human label for what next() will do
changedSet of memory keys touched by the last ② reflect (e.g. 'canvas', 'item/ring', 'user/boromir') — drive change highlights off this
decision / lastActionthe ③ act beat's choice + written action (lastAction carries the rationale)
canPrev / canRedoguard your prev/next buttons

Data shapes

Every model-backed beat is bound to a JSON Schema (items[] requires a non-empty title, tool is an enum, text is non-empty). A wired model returns it via forced tool-calling, so output can't come back malformed; a bad response still degrades to {} rather than throwing.

message = { author: id, text: string }
item    = { id, ...surface.shared, ...surface.notes, contributions: { [id]: contribution } }  // a surface item (state)
record  = { mind: { interest, position, beliefs, arc, regards: [{ toward, note }] }, confidence, plan: { watchingFor, helpNext }, shifts: [{ turn, observed, from, to }] }  // Iris's per-person record
delta   = { items: Partial<item>[], contribution?: { item: id, value: object }, privateNotes: [{ id, interest?, position?, beliefs?, arc?, confidence?, watchingFor?, helpNext?, regards?, shift? }] }  // ② reflect — flat patch, routed into the layers
choice  = { tool: 'none'|'post'|'caucus', target: id|null, rationale: string }  // ③ act · decide
action  = { tool: 'none'|'post'|'caucus', target: id|null, text: string }       // ③ act · write

The item's fields come from the surface schema (shared + notes); contribution carries an explicit sealed value like a vote. item (durable state) and choice (what decide() returns) are different things.

Extending

The demo is driven by scenario-elrond.js (participants, room, MESSAGE_QUEUE, mock* fns); a live model is any ({ system, prompt, schema? }) => Promise<string> you inject.

Iris SDK · prototype. Two layers: this runtime (iris-sdk.js) + a display layer (index.html). See the running prototype at /prototype/.