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.
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 agent | Iris — a collaboration agent | |
|---|---|---|
| Principal | one user | a group — participants |
| Channels | one conversation | a shared room + a private caucus(id) per participant |
| Memory | one pool it fully sees | memory.private[id] — sealed per participant |
| What it may say | anything it knows | scoped by context({ audience }) — can't leak what it can't see |
| Shared artifact | none (just the transcript) | a co-owned surface → room.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)
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.
| beat | Trigger / method | Model? | What it does |
|---|---|---|---|
| ① human event | ingest(author, text) | no | a line enters the room — appended to the log; this starts the turn |
| ② update memory | updateMemory() | yes | open / advance / close any surface items this line moves; re-derive their heartbeat + read |
| ③ introspect & act | decide() + act() | yes | reason 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:
participants— the group:[{ id, name, role }]. First-class, in the constructor.policy· global — the system prompt. Passed in, mutable, injected on every model call.room· the shared channel —{ topic, log[], messages[], items{} }— the transcript plus the surface items (all of them; no privileged “active” one).memory· partitioned —{ shared, private{ [id]: { mind, confidence, plan, shifts[], caucus[] } }, introspection[] }. Everything Iris privately holds about a person, in her voice, never shown to anyone — in three honest layers so only the first is truly a theory of mind:mind(the ToM, BDI:interestdesire,positionintention,beliefswhat they assume,arcdisposition,regardshow they see the others);confidence(how sure she is — meta, not a fact about them);plan(watchingForthe open question she's tracking,helpNexthow she can help them next — her agency, not their mind); andshifts[](append-only log of how their mind moved). She patches facets and appends shifts, never rewrites.private[id].caucusis the 1:1 thread (alsoiris.caucus(id)).introspection· Iris's own — a first-person log of what she decided each act beat, and why (iris/introspection/log.md). Written every beat, hold included. Private; it never leaves memory.
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:
'self'— everything, incl. every sealed read. Used only for the agent's own private beats (reflect,decide), whose output stays in memory.- a participant id — the transcript, the public board, and only that one person's sealed file + your 1:1. Used when writing a
caucus. 'room'— transcript + the public board only, no sealed anything. Used when writing apost.
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:
| Preset | Iris acts… | Concretely |
|---|---|---|
policy-facilitator.md | for the group | caucuses people herself, then posts a de-identified synthesis in her own voice |
policy-coach.md | through a person | privately 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:
| Surface | Items | Sealed per participant | Public projection |
|---|---|---|---|
decisions | things to decide | a stance (presence) | status + resolution + weighed-in count |
poll | options to choose | a vote | tallies 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.js — post 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, …).
| Tool | Audience | What it does |
|---|---|---|
post | room | speak to the whole room |
caucus | person | private 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
| Adapter | Config | When |
|---|---|---|
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 |
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
| Method | Returns | Notes |
|---|---|---|
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 }) | string | the assembled, audience-scoped context (the confidentiality primitive) |
stepper(queue?) | Stepper | debug 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
| Member | Notes |
|---|---|
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 |
changed | Set of memory keys touched by the last ② reflect (e.g. 'canvas', 'item/ring', 'user/boromir') — drive change highlights off this |
decision / lastAction | the ③ act beat's choice + written action (lastAction carries the rationale) |
canPrev / canRedo | guard 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
- A new surface — the co-owned artifact is a plug-in schema, and it's the real extension point. Beyond
decisionsandpoll, author one for whatever a group builds together:tasks(assignee / done),availability(“N of M free”),concerns(de-identified),terms(agreed clauses). Declare{ shared, notes, contribution, statuses, fields, seedItem, aggregate }and the substrate gives you items, participation, projection, and confidentiality for free —aggregateis where you decide what stays sealed and what reaches the room. - A different agent — swap the
surface+policy(+ any customtools) and the same class is a different collaboration agent: a standup bot (tasks+ a nudging policy), a neutral vote-teller (poll+ a neutral policy). The policy is how it thinks, the surface is what it builds, the tools are what it can do — and the multi-principal plumbing (channels, sealed memory, de-identified projection) never changes. To retune wording without a new policy, edit the per-beat task strings in_modelUpdateMemory / _modelDecide / _modelAct.
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/.