Group Conversations
Multi-agent structured discussions with moderator synthesis.
Overview
Group Conversations enable multiple agents to discuss a question. Each agent participates through its normal pipeline — agents are group-unaware by default. A GroupConversationService orchestrates the discussion through configurable phases.
Discussion Styles
ROUND_TABLE
Opinion × N → Synthesis
Brainstorming, open-ended exploration
PEER_REVIEW
Opinion → Critique → Revision → Synthesis
Code review, document review
DEVIL_ADVOCATE
Opinion → Challenge → Defense → Synthesis
Risk assessment, stress-testing
DELPHI
Anonymous rounds → convergence → Synthesis
Forecasting, reducing groupthink
DEBATE
Pro → Con → Rebuttals → Judge
Trade-off analysis, comparisons
TASK_FORCE
Plan → Execute → Verify → Synthesis
Structured task decomposition, parallel execution
NEGOTIATION
Positions → Proposals → Bargaining → (Arbitration) → Synthesis
Surfacing trade-offs, drafting compromises
CUSTOM
Define your own phases
Any workflow
Quick Start (MCP)
# 1. Discover available styles
describe_discussion_styles
# 2. Create a group
create_group(
name="Architecture Review",
memberAgentIds="expert-1,expert-2,expert-3",
memberDisplayNames="Backend Expert,Frontend Expert,DevOps Expert",
moderatorAgentId="moderator-agent",
style="PEER_REVIEW"
)
# 3. Run a discussion
discuss_with_group(groupId="<id>", question="Should we use microservices?")Quick Start (REST)
One agent, one seat. A member is addressed everywhere by its agentId — its member conversation, display name, vote weight, participants lists and the facilitator all key on it — so listing the same agent twice does not give the group two panelists: both seats would speak through one member conversation, and the "independent" second answer would read the first as its own history. A group config that repeats a member (same agentId, whatever the member type — the runtime keys every seat by the id alone) is rejected, naming both positions: REST POST/PUT /groupstore/groups answer 400, and the MCP tools create_group, update_group and create_group_from_template return the same message as a JSON error payload ({"error": "<tool> failed: members[1] repeats members[0] …"}) — MCP tool calls have no HTTP status of their own. For a second seat with the same behaviour, duplicate the agent and add the copy. A group already stored with a repeated member still loads: each repeat is dropped on read — the first seat wins, with its display name, role and speaking order — and a warning is logged; saving the group once stores the cleaned list. One case needs a roster change first: if the dropped seat held a role a preset style is built on — typically one agent seated as both PRO and CON in a DEBATE — the save is refused with the ordinary role message naming the missing role (that group never had two sides; one conversation argued both). The warning logged on read names the dropped role; put a second agent in that seat and save.
Member Roles
Some styles require specific roles. A preset DEBATE group without at least one PRO and one CON member, or a preset DEVIL_ADVOCATE group without a DEVIL_ADVOCATE member, is rejected at save time — the engine would otherwise fall back to ALL members and the style would silently become something else. (Groups with explicit phases route roles themselves and are not checked.)
DEVIL_ADVOCATE
DEVIL_ADVOCATE style
Argues against consensus
PRO
DEBATE style
Argues in favor
CON
DEBATE style
Argues against
Debate verdicts
When a DEBATE has two sides and an impartial judge — i.e. members carry at least two distinct roles and moderatorAgentId names an agent that is not one of the debaters — the judgment phase returns a structured verdict alongside its prose, on the conversation's decision field:
winner is null for a tie (the tie is stated in outcome). The judge is asked to score argument quality and factual support, and explicitly not assertiveness or fluency.
Any of these leaves decision unset and the conclusion as ordinary prose — none of them is an error:
No roles on the members. Nothing argued PRO, so a PRO-vs-CON score would be invented rather than measured.
No moderator. The stand-in synthesizer is one of the debaters, and a partisan's call is not the group's finding.
A phase
inputTemplateof your own. Your instruction wins; set one if you want a debate to conclude in plain prose.A judgment the parser cannot read. The prose conclusion is kept as-is.
Agent-filed tasks
Work an agent discovers mid-discussion — a missing migration, an untested edge case — would otherwise die in prose: the shared task list is written only by the PLAN phase and by config. Turning on taskListConfig gives members two tools, addGroupTask and listGroupTasks, and a filed task is picked up by the next execution wave with no other changes.
Off by default, and absent rather than refusing when off. A tool that is not assembled costs no prompt tokens and cannot be argued with; one that exists and always says no invites retries. The tools appear only when the turn belongs to a live group discussion whose config sets allowAgentTaskCreation: true — a standalone agent, a paused discussion, or an unreadable config all yield no tools.
addGroupTask(subject, description, dependsOnSubjects?, priority?, assignToRole?)
dependsOnSubjectsnames other tasks by their subject, aslistGroupTasksprints them. An unknown name is refused, not dropped — silently filing a task without its dependency schedules it immediately, which is the opposite of what was asked.assignToRoletakes"ROLE:Reviewer"or a member's exact name, and files the task already assigned. Omitting it (or passing"ALL") round-robins the task to the next member through the same resolver the PLAN phase uses — every filed task gets an owner, and filing is refused outright if the team has nobody to own it. An unmatched role is refused with the available roles named.Refusals are sentences aimed at the model: duplicate subject, unknown dependency, circular dependency, subject over 200 chars, description over 4,000, and either cap being reached.
Both caps are enforced independently: maxPerTurn bounds a runaway single turn, maxAgentAddedTasksPerDiscussion bounds slow drift across a long discussion. The discussion cap counts only agent-filed tasks, so a large planned backlog does not exhaust it. A rejected call does not consume the per-turn budget.
No claim or complete tools. The wave loop owns every task-state transition; a second writer racing it would corrupt the state machine that decides what runs next. Filing is the only agent-side write.
Transcript windowing
A FULL- or ANONYMOUS-scope discussion phase re-feeds the transcript to every member every turn, so a long discussion grows roughly quadratically in prompt tokens. contextWindow bounds what each turn renders — the stored transcript is never modified, and signing verification still runs on the raw entries:
When the scope-filtered context exceeds maxRecentEntries, the older entries collapse into a single leading block: a rolling summary (extended incrementally at phase boundaries via the shared summarization service — never per member turn), or, with summarizeOverflow: false, a plain [n earlier entries omitted] marker that costs no LLM call. A summarization failure never blocks the discussion — the turn renders with the truncation marker and the next boundary catches up.
Applies to
FULLandANONYMOUSscopes only. TheANONYMOUSvariant keeps its own summary, built from the same"Anonymous"labels the scope filter produces — the summary can never de-anonymize a peer.llmProvider/llmModelname the summarizer; without them, overflow falls back to the truncation marker (a save-time warning says so).The optional prices attribute the summarizer's own spend to the discussion's cost ledger, where
maxCostPerDiscussionsees it.The convergence judge's input is not windowed — it is already bounded to the last two rounds.
Member stances (the overview dashboard)
The Manager and the Workforce board can render a discussion as an overview instead of a transcript — a phase rail, a members x phases matrix, and a roster of one-line positions. The roster's lines are member stances, and they always exist: by default each is the lead sentence of that member's newest contribution, which costs nothing and is the member's own words.
stanceSummary upgrades that to an LLM summary of everything the member has said:
The summarizer's input is bounded. It reads the member's newest contributions within
maxInputChars(default 8,000, ceiling 32,000), each cut tomaxEntryChars(default 2,000, ceiling 8,000), with an[N earlier contribution(s) omitted]marker — a stance is where the member stands now, and the whole history re-sent at every boundary eventually overflows the summarizer's context window. Non-positive values take the default.There is no
enabledflag, unlikecontextWindow. Stances exist either way, so the only thing a flag could have meant is "may this spend money?" — which is already what naming a provider and a model means.Stances are recomputed at phase boundaries, and only for a member whose stored stance no longer covers their own contributions. A member who stayed silent through a phase costs nothing — coverage is counted per member, not against the transcript length, or one member speaking would re-bill everyone.
The summarizer is optional spend and obeys
maxCostPerDiscussion, like the window summarizer and the convergence judge. Past the ceiling it downgrades to extraction rather than stopping the phase.A summarizer failure degrades to lead-sentence extraction, never to a blank roster, and never fails the discussion.
The UI distinguishes the two producers — an extracted line is shown as a quotation, a generated one as a summary — because presenting a paraphrase the way a quotation is presented would misattribute it.
Extraction skips the entry types that carry a JSON contract (
VOTE,BID,RETRO,PLAN,TASK_RESULT,VERIFICATION) and quotes the newest prose entry instead — the lead "sentence" of a ballot is an opening brace, and it would otherwise replace a member's real position after every vote. The LLM summarizer still reads them.The optional prices attribute stance spend to the same cost ledger
maxCostPerDiscussionbounds, under asystem:stance:*key.Naming only one of
llmProvider/llmModel(or prices with neither) produces a save-time warning: that config intends to spend and silently will not.
Both surfaces stream two events for this view: cost_updated after every cost attribution (carrying the key's cumulative spend, so a redelivered frame is idempotent) and stance_updated whenever a stance's text changes.
Voting
A VOTE phase collects explicit ballots instead of another round of prose. LLM ballots are correlated (shared priors, sycophancy) — the durable value is the auditable artifact: the weighted tally, every raw ballot, and the losing side's statements recorded as dissents on the DecisionRecord.
Independence is enforced structurally, not advised. Save-time validation rejects a VOTE phase that is not PARALLEL + contextScope: NONE; ballots are cast blind against the pre-fan-out snapshot, and VOTE entries stay peer-hidden until their phase completes (commit-reveal).
Ballot contract:
{"vote": "<exact option text>", "confidence": <0..1>, "statement": "..."}(APPROVALuses"votes": [...]). Three-tier parse: strict JSON → JSON embedded in prose → a reply naming exactly one option's text. Anything else is a non-ballot and counts against quorum — as do abstentions; a mostly-silent team has not reached quorum, and that is signal.Only what was cast counts. For a JSON ballot, only the
vote/votesvalues are read, never the free-textstatement. A cast in a shape the method did not ask for still counts when it names exactly one option (the approval array{"votes": ["X"]}underMAJORITY, a string"votes": "X"underAPPROVAL).An empty ballot is a non-vote.
"votes": [],"vote": nullor a JSON ballot with no vote field at all counts against quorum — even if its statement mentions an option.Options are matched as words. In prose ballots (and the moderator's tiebreak reply) an option must appear as a whole word or phrase, case-insensitively: option
Nois not found inside "not" or "know". The boundary applies only between words of scripts written with spaces — in Chinese, Japanese, Thai and similar scripts a plain substring match is used, so "我支持方案A。" is a vote for方案A.
Options:
EXPLICITis the reliable path.LAST_SYNTHESISextractsOption A: …lines from the newest synthesis — instruct that synthesis to emit them (the default synthesis prompt does not). The line may be written the way models write it:**Option A:** …,- Option B: …,1. Option C) …,Option D. …, in any letter case.Ties and quorum failures go to
tiePolicy:MODERATOR_DECIDESruns one moderator turn choosing among the unresolved options (methodvote+moderator-tiebreak);NO_DECISION(default) records an honesttype: NONEand the discussion continues.HUMAN_DECIDESis still rejected at save time: humans can now be group members (I6), but a tie-break that waits on a person needs its own resume machinery, which has not shipped.The result fires the
decision_reachedSSE event (which this feature also wires for debate verdicts) and renders a tally block in Slack.
Humans as group members (I6)
Real deployments are hybrid teams: a memberType: "HUMAN" member sits in the roster like any agent, but their turn pauses the discussion (AWAITING_HUMAN_INPUT) until they answer.
The human's
agentIdis their principal id — the identity that may submit their turns;displayNameis required at save time.Their prompt is rendered exactly like an agent's and persisted on the conversation (
pendingHumanInput.renderedPrompt); thehuman_input_requestedSSE event (and a Slack notice) says who is up.Submission:
POST /groups/{groupId}/conversations/{id}/human-input{memberId, content}or MCPsubmit_group_human_input. Only the member's own principal (or an admin) may submit — aneddi-approvermay decide approvals, but speaking as another human is impersonation, not review. The answer is recorded as the phase's natural entry type (a human OPINION is an OPINION) and the discussion resumes from the next speaker.This is deliberately NOT the approval surface: approve/reject endpoints never accept free text, and the pending-approvals inbox marks these entries
pauseType: "HUMAN_TURN"with the member's id, so a human sees their own pending turns without owning the conversation.Timeouts (
humanMemberConfig):turnTimeout(ISO-8601; unset = wait indefinitely) withonTimeout: SKIP_TURN(a SKIPPED entry — "no response from within " — and the discussion moves on) orABORT(graceful cancel). Timeout schedules survive restarts via the HITL crash-recovery sweep.PARALLEL phases: agents fan out first; humans are then prompted one at a time against the pre-fan-out snapshot, so an independent round stays independent — a human answering after the agents cannot read their answers.
v1 bounds (save-time rejected): no HUMAN members in task-force groups (PLAN/EXECUTE/VERIFY) or
targetEachPeerphases, and a group containing humans cannot be nested as a GROUP member. A human moderator is allowed — every synthesis then waits on that person (the save warns about it).
Facilitator with bounded moves (I12)
Orchestration is static choreography; nothing reacts ("we've agreed — stop", "we need a specialist — recruit"). A full LLM orchestrator conflicts with deterministic governance, so the facilitator chooses among config-enumerated moves — validated, capped, audit-logged:
At each checkpoint (EACH_PHASE default, or EACH_REPEAT) the facilitator agent receives a compact briefing — phase/repeat position, budget arithmetic, roster, entry counts, capped excerpts, never the full transcript — and replies {"move", "args", "reason"}. Moves v1:
CONTINUE— the ambient no-op and every failure's fallback. Silent.END_PHASE/EXTEND_PHASE— skip the remaining repeats / add one (≤2 extensions per phase, still bounded bymaxTurns). They act mid-phase, so the save rejects them unlesscheckAfter: EACH_REPEAT. A convergence exit is never overruled.CALL_VOTE— inserts a one-off VOTE phase next (args.options, 2–10), built to I14's enforced PARALLEL+NONE shape by construction.RECRUIT— brings a deployed agent in (args.agentId), the same validation path as I7'srecruitAgenttool.ESCALATE_HUMAN— pauses the discussion (AWAITING_HUMAN_INPUT) for the configuredescalateToprincipal with the facilitator's question, riding I6's pending-input machinery whole; the answer records as a peer-visibleFOLLOW_UPand the discussion resumes.
Bounds and honesty. The consult itself is a real LLM turn (counted, cost on the I1 ledger, skipped once either budget is gone). Executed non-CONTINUE moves are capped by maxMovesPerDiscussion (default 10) and each lands as a peer-hidden FACILITATION entry + audit event + the eddi_group_facilitator_moves_total{move,outcome} counter. Anything unparseable, disallowed or invalid-in-context degrades to CONTINUE and is recorded as a rejected attempt — the audit trail must show the model tried. Facilitator failure never fails a discussion.
Runtime phase divergence. CALL_VOTE and EXTEND_PHASE mutate a runtime copy of the phase list, persisted on the conversation (schema v4). Every resume path — approval, human turn, timeout skip — executes and drift-checks against that list, so a pause taken inside an inserted phase resumes correctly. The divergence is one-off: completion (and every new round) starts from the config again.
Negotiation (I11)
EDDI's other decision forms are win/lose; NEGOTIATION is the trade form — a process for surfacing trade-offs whose output is a drafted compromise with an explicit concession ledger for human sign-off.
The preset: ① Positions & Interests (parallel, context-free — interests enable integrative trades) ② Opening Proposals ③ Bargaining (repeats = maxRounds, exits early on agreement) ④ Arbitration (moderator; skipped entirely when an agreement was reached — skipIf: "AGREEMENT_REACHED", the single deterministic skip condition) ⑤ Synthesis.
A BARGAIN turn is a typed move:
{"accept": "<proposalId>"|null, "proposal": {"terms": "..."}|null, "concessions": [{"gaveUp": "...", "inReturnFor": "..."}]}plus free-text reasoning. Three-tier parse; an unreadable turn is prose with no state effect.The typed structure is the anti-sycophancy mechanism: an acceptance must name a specific proposal id, a concession that names nothing in return is not recorded, and the open proposals + ledger are quoted into every turn — the record the outcome will cite.
A new proposal supersedes the mover's own open one (one live offer per agent); the proposer signs their own terms implicitly.
Agreement = every non-moderator participant signed the same open proposal. The bargaining phase ends its repeats early and the conversation carries
decision: {type: "AGREEMENT", method: "negotiation"}whosetally.signedAcceptancesmaps each signatory to the transcript index of their signed acceptance entry — the (already signed) entries are the co-signatures; no new crypto.No agreement → the arbitration runs and its conclusion becomes
decision: {type: "VERDICT", method: "arbitration"}.The ledger is bounded — it is an LLM write surface quoted into every later turn. One BARGAIN turn records at most
maxConcessionsPerMoveconcessions (default 5, ceiling 20; each side of a concession is stored truncated to 600 characters), the ledger stops atmaxLedgerConcessions(default 50, ceiling 500 — the earliest are kept, they are the record the outcome quotes; further concessions are not recorded but counted, and the agreement outcome says "N further concession(s) not recorded (ledger full)" and carriestally.concessionsNotRecorded), and a turn's prompt quotes only the newestmaxRenderedConcessions(default 20, ceiling 100) with an "(N earlier concession(s) omitted)" line.negotiationConfigis a default, not a switch: without it the defaults apply.
Retro → group memory
A RETRO phase stops discussions from evaporating: the group reviews how it worked and distills lessons that persist as team-owned group memory, then surface as {properties.*} in every member's later discussions — institutional knowledge that compounds run-over-run.
The built-in template asks for
{"lessons": [{"lesson": "...", "context": "..."}]}; parsing is three-tier (strict JSON → embedded JSON → nothing) and the per-run cap is enforced at parse time whatever the model produced.Lessons are stored under the synthetic owner
group:<groupId>withgroupvisibility — they belong to the team, not to whichever human ran the discussion, and survive that human's GDPR erasure without carrying their identity. The idempotency keyretro:<hash(lesson)>makes re-running the same retro a no-op.Bounded growth is non-negotiable: the stored set FIFOs at
maxStoredLessons— storing past the cap evicts the oldest, ordered by creation time, so re-harvesting an old lesson cannot shield it from eviction. Both caps are clamped to hard ceilings (20 per run, 500 stored) no config can exceed: an LLM-driven write surface with an operator-supplied ceiling is only as bounded as the operator's typing.maxLessonsPerRunbounds the whole harvest, not each contribution — a RETRO phase with several participants or repeats cannot multiply it.maxLessonChars(default 1000 — the same default as an agent'smemoryGuardrails.maxValueLength; ceiling 4000) bounds one stored lesson value, lesson plus its "applies:" context. Longer lessons are truncated, the context first; the idempotency key still hashes the untruncated lesson.Member conversations already load group-visible entries at init, so recall needs no new namespace. The
retro_recordedSSE event reports each harvest.
Standing Teams (I13)
A group conversation is an episode; a team persists. The GroupWorkspace (one document per group, own collection — stored under the group's own id, so two pods creating it at once end up with exactly one, see below) holds what survives between episodes: a backlog (a SharedTaskList, so pulled tasks flow straight into the task-force machinery), cadences that pull from it on a schedule, and the team's running metrics — deliberately thin glue over the existing schedulers, I1's cost ledger and I8's retro memory.
cronExpression is a 5-field Unix cron (min hour day month weekday), not 6-field Quartz — "0 0 3 * * ?" is rejected. The error message says so, but the example above is the one to copy.
MCP: add_team_task, list_team_backlog. The backlog caps at 200 with an actionable error — it is a working set, not an archive.
Cadence fires ride the schedule machinery whole (SchedulePollerService claim/lease/retry/dead-letter; the executor branches on teamCadenceType: "team_cadence" exactly like Dream consolidation). Each fire:
Reconcile — a finished previous run is written back first; one still in flight skips the fire. A run paused at an HITL gate is not terminal, so it holds the claim — but only up to a TTL (see Stale claims below).
Pull — top-N executable backlog tasks by priority; an empty pull skips, logged.
Run — the discussion is started first, so the claim can carry its real id. Pulled tasks are injected as a runtime copy of
config.tasks(the stored config is never written) and the cadence'smaxCostPerRunrides the inherited-ceiling slot: dollar-primary, per the Dream precedent. The discussion runs under the cadence creator's identity.Claim — a conditional store write on
runningDiscussionId, taken before any task turn can complete and without any in-JVM lock. Two pods firing concurrently both start a discussion; the one that loses the CAS cancels its just-started discussion before its first turn, so only one fire does work.
Writeback happens at the next fire (or on a workspace read — read-repair), never from inside the discussion thread, so a pod crash mid-discussion loses nothing. It also means a fire's COMPLETED status says the discussion was started, not that its tasks are done: backlog statuses change only once that writeback has run. VERIFIED outcomes stay VERIFIED on the backlog and credit the assignee's perMemberStats; anything else returns to PENDING with the reviewer's feedback appended to the description — the cross-run retry loop. A FAILED, REJECTED or CANCELLED discussion returns every pulled task untouched. Retro lessons flow through I8 unchanged (no duplication).
Stale claims are reclaimed. The default group HITL timeout policy is WAIT_INDEFINITELY, so a pause nobody resolves never reaches a terminal state: without a backstop the claim would be held forever, every subsequent fire skipped as "still running", and the pulled backlog tasks stuck IN_PROGRESS. A claim older than eddi.groups.cadence.claim-ttl (default PT24H) is reclaimed — the stranded discussion is cancelled and put through the ordinary failure writeback, so pulled tasks return to PENDING and the claim clears. Cancel happens before release, so a reclaimed run cannot keep spending against a budget nobody is tracking. Set the TTL non-positive to disable reclaiming, for an operator who would rather wedge than risk abandoning a pause; the counter is eddi_team_cadence_claims_reclaimed_total. Creating a cadence on a group that combines requiresApproval phases with WAIT_INDEFINITELY logs a warning — that combination is what makes the backstop reachable — but is not rejected: it is legitimate for a team whose approver really is always available.
Creation is atomic. The workspace is created on first use, and that first use can happen on several pods at once (two backlog adds, a cadence fire and a read). It is written with an insert-only write under the group's id, so the database's primary key admits one creator and every other caller adopts that document. Up to 6.5.0 each creator inserted its own document and they converged afterwards on the lexicographically smallest id — which is not the first insert (ObjectIds are minted client-side, UUIDs are random), so a caller could get back a document that later reads did not elect, and a backlog task it wrote there reported success and vanished. Workspaces created by earlier releases keep their generated id and are still found by their groupId.
Per-member stats are reliability recording only — nothing routes or weights on them in v1. A permanent group deletion cascades to the workspace; a soft (versioned) delete keeps it, because the group can come back. Not in v1: cross-team handoff, Manager UI, reputation weighting.
Shared artifacts (blackboard-lite)
Without artifacts, the transcript is the only medium — every structured thing an agent produces is prose the next agent re-parses. artifactConfig gives members four tools to create together: createArtifact(name, type, content), readArtifact(nameOrId), proposeArtifactUpdate(nameOrId, content, expectedVersion, markFinal?) and listArtifacts(). Artifacts are typed documents (TEXT, MARKDOWN, JSON) in their own collection, listed on the discussion's REST/MCP status payload as artifacts, and announced over SSE and Slack as artifact_updated events.
Concurrency is deterministic compare-and-set, not an LLM merge. Every update presents the version it read; a stale writer is told "artifact changed since you read it (now v3); re-read and merge your change" and retries against fresh content. The failure mode is a retry, never a silent bad merge.
Validators are declarative only — JSON_SCHEMA, REGEX (content must contain a match), MAX_LENGTH (characters) — never code. Specs are checked at config save time; at write time a failing validator refuses the write with its message and stores nothing. Content is additionally capped at 256 KB per artifact.
Off by default with the same absence discipline as the task tools: no opt-in means the tools are never assembled. The member agent's own enableBuiltInTools switch still applies — a member whose LLM task does not set it takes part without the tools, and saving a group that enables artifacts, agent task creation or dynamic agents logs that prerequisite. Artifact, task and dynamic-agent tool results are never served from the tool cache: every member runs as the same user, so a cached listArtifacts() would hide a peer's new artifact. markFinal: true freezes an artifact — FINAL artifacts accept no further updates. Artifacts are deleted with their discussion (close/delete cascade) and by GDPR erasure; the durable trace of the work is the transcript.
Preset Templates (I10)
Enterprises understand "research pod" and "decision board", not contextScope: OWN_FEEDBACK. Packaged templates ship complete, validated group configs whose member slots are named roles — instantiation just assigns agents (or human principals) to them and saves through the normal store path, so every save-time validation applies.
MCP: list_group_templates, create_group_from_template. Missing or unknown roles fail loudly, naming the template's real roles.
research-pod
DELPHI-style CUSTOM
Blind estimates → anonymous convergence rounds (I2) → synthesis → retro into team memory (I8), windowed context (I9), dollar ceiling (I1)
editorial-team
CUSTOM
Writer drafts a shared artifact (I17), editors propose CAS-guarded updates, dissents recorded (I4)
ops-task-force
TASK_FORCE
Bid-based assignment (I18), agent-filed tasks (I5), specialist recruitment (I7), ceiling
decision-board
CUSTOM
Hybrid board with a HUMAN director (I6) deliberating and voting (I14); options distilled by the chair, ties to the chair
negotiation-table
NEGOTIATION
Typed two-party bargaining with concession ledger (I11); the arbiter role may be a human principal
Attachments
A discussion can carry shared files. POST /groups/{groupId}/conversations (and the /stream variant) accepts an attachments array alongside question, in two shapes — hosted (mimeType + url) and inline (mimeType + data, optionally fileName):
Inline data is stored in the blob store owned by the group conversation, so it is granted to members and reaped with the conversation. Hosted url references pass through as-is. A ref carrying neither is skipped, as is a url ref with no mimeType.
How members receive them. On a member's first turn the orchestrator grants that member's private conversation access to the group's blobs and injects them as attachment_* context — from there the ordinary single-agent attachment path applies (multimodal forwarding for vision models, PDF/text extraction otherwise). On later turns the member's own conversation history carries them: AttachmentForwarder notes the earlier attachments and the readAttachment tool is auto-enabled for any conversation that has them, independently of builtInToolsWhitelist. A recruited member gets the same grant on its own first turn, and a nested GROUP member propagates the whole set down.
Bounds worth knowing:
The per-turn forwarding cap (
eddi.attachments.max-per-turn, default 5) applies to each member turn. A discussion sharing more than that will have the surplus dropped for that turn, reported in the member'sattachments:errors— visible in that member's conversation, not in the group transcript.url-only attachments are not blob-backed and are not re-hydrated after a HITL resume; blob-backed ones are.
Nested Groups (Group-of-Groups)
Members can be other groups. The sub-group runs its own discussion and its synthesized answer becomes the member's response.
Depth tracking prevents infinite recursion (eddi.groups.max-depth, default: 3). A group that would contain itself through its GROUP members (directly or via other groups) is rejected when it is saved.
Custom Phases
For full control, define phases directly:
A CRITIQUE phase with targetEachPeer: true has every member critique each peer in turn. Without it, each member reviews all peers' latest responses in one turn.
Per-phase controls
Beyond type/participants/turnOrder/contextScope, a phase carries four controls that shape when it stops and what it costs:
repeats runs the same phase N times over the growing transcript — the mechanism behind DELPHI's rounds.
requiresApproval pauses the discussion at the phase boundary until a human approves or rejects (see HITL). At TASK granularity on an EXECUTE phase it pauses per submitted task instead.
convergence (I2) ends a repeating phase early once the round stopped producing movement, so a group that agreed after round 2 does not pay for round 5. A judge compares this round's contributions against the previous round's and scores similarity against threshold; minRepeats (minimum 2 — one round has nothing to compare against) guards against calling convergence before there is evidence. judge selects who scores it: MODERATOR runs the moderator agent as the judge (in its own conversation, so the judge's JSON-only prompts never leak into a later SYNTHESIS turn). SERVICE is accepted but not yet wired — it logs a warning and falls back to the same moderator-agent path, so it currently costs the same as MODERATOR rather than the cheaper dedicated call the name suggests. Either way, a group with no moderator skips the judge and the phase simply keeps going. Converging is a real completed repeat: it still persists, still fires onPhaseComplete, and still runs the phase's decision block.
allowAbstention (I4) appends an instruction telling members they may reply with the single token PASS when they have nothing to add. A passing member produces an ABSTAINED entry carrying no content: peers, the synthesizer and the convergence judge never see it, so a late DELPHI round costs one short call instead of three agents restating agreement in prose. The entry is still counted — a round in which every scheduled participant abstained ends the phase through the unanimous-abstention exit. Task phases never advertise the token — the detector and the prompt read the same switch, so they cannot disagree.
Dissent (I4)
"recordDissents": true on the group gives every participant who did not write a synthesis one short turn to state where they still materially disagree. Non-PASS replies become public DISSENT transcript entries and populate decision.dissents.
It is opt-in because it costs one extra short call per non-synthesizer. It is nonetheless the honest design: the alternative is asking the synthesizer to report the objections to its own synthesis, which is precisely the failure mode a minority report exists to prevent.
Phase Types
OPINION
Share perspective on the question
CRITIQUE
Review another member's response
REVISION
Revise own response based on feedback
CHALLENGE
Argue against consensus (devil's advocate)
DEFENSE
Defend position against challenges
ARGUE
Present argument for a side (debate)
REBUTTAL
Counter opposing arguments
PLAN
Decompose the question into sub-tasks
EXECUTE
Work on assigned sub-task
VERIFY
Review and validate another member's work
SYNTHESIS
Moderator produces balanced conclusion
VOTE
Collect explicit ballots and tally a decision (I14)
PROPOSAL
State an opening offer in a negotiation (I11)
BARGAIN
Counter, concede, or sign an acceptance (I11)
RETRO
Draw process lessons into team memory (I8)
Context Scopes
NONE
Only the question (independent)
FULL
All previous transcript entries
LAST_PHASE
The previous phase's entries and the current phase's so far — in a sequential phase, that is what lets the second speaker react to the first
ANONYMOUS
Previous entries with speaker names removed
OWN_FEEDBACK
Only feedback addressed to this agent
TASK_ONLY
Only this agent's assigned task from the plan
TASK_WITH_DEPS
Assigned task plus outputs from dependency tasks
TASK_FORCE Configuration
The TASK_FORCE style uses a 4-phase pipeline: Plan → Execute → Verify → Synthesize.
PLAN — The moderator decomposes the goal into actionable tasks and assigns each to an agent
EXECUTE — Agents execute their assigned tasks in parallel (each sees only
TASK_ONLYorTASK_WITH_DEPScontext)VERIFY — The moderator reviews each task result against the original goal
SYNTHESIS — The moderator combines all verified results into a coherent final deliverable
Pre-Configured Tasks
Pass a tasks array to skip the PLAN phase entirely — useful for deterministic, repeatable workflows:
When tasks is provided, the system posts [System] "Pre-configured task plan: N tasks" instead of invoking the moderator's LLM. A CUSTOM group whose phases include EXECUTE but no PLAN gets the same materialization at the start of EXECUTE. A requiresApproval gate on a PLAN phase does not apply there, because there is no plan phase to pause after.
Task Dependencies
Use dependsOn to create sequential execution chains. Each entry references a task subject:
Tasks with no dependencies execute in parallel
Tasks with dependencies wait for their predecessors to complete
Dependent tasks receive their predecessor's output via the
TASK_WITH_DEPScontext scopeCycle detection prevents circular dependency chains (fails fast at planning time)
Task Statuses
PENDING
Waiting for dependencies or execution
ASSIGNED
Assigned to an agent, waiting to start
IN_PROGRESS
Currently being executed by an agent
COMPLETED
Agent produced output
VERIFIED
Moderator verified the result
BLOCKED
Reserved for dependency/resource blocking — recognized as non-terminal, but the engine does not set it yet
AWAITING_APPROVAL
Result submitted, waiting on a human (TASK-granularity HITL)
FAILED
Agent or verification failed
Bid-based assignment (I18, CNP-lite)
The planner cannot know members' actual fit or load. Setting assignmentMode: "BID" — per task on a TaskDefinition, or as the group default on taskListConfig — leaves those tasks unassigned at PLAN time; the execution wave announces them to eligible members in blind, parallel bid turns and awards each to the highest self-assessed confidence:
Members reply
{"bids": [{"subject", "confidence": 0..1, "estimatedComplexity": "XS|S|M|L", "rationale"}]}— three-tier parse; prose casts no bids; bids on unannounced tasks are dropped; confidence is clamped.Blind: the bid prompt carries the announced tasks and nothing else — no transcript, no peer bids. Bid replies land as
BIDtranscript entries (peer-hidden while the phase runs, auditable afterwards).Deterministic award: highest confidence; ties break by speaking order, then agent id. The winning bid is recorded per task (
taskList.awardedBids[taskId] = {agentId, confidence, estimatedComplexity, rationale}).Never stalls a wave: a task nobody bid on falls back to the ROLE path, and the auction skips itself entirely (logged) when it cannot beat its own overhead — fewer than 2 eligible bidders or fewer than 2 unassigned tasks — or when the remaining turn budget cannot cover one bid turn per member.
Bid turns are real member turns: they count toward the turn budget and their cost lands in the discussion's cost attribution.
Dynamic Agents
During TASK_FORCE (or any group) discussions, agents with the appropriate LLM tools can create, recruit, and delegate to new agents at runtime:
CreateSubAgentTool
Create a new ephemeral agent with a specific system prompt
ConverseWithAgentTool
Delegate a sub-task to an existing deployed agent
FindAgentsByCapabilityTool
Discover agents by capability keywords
RecruitAgentTool
Bring a discovered agent into the discussion as a member
TeardownAgentTool
Clean up dynamically created agents
Recruitment
Discovery and recruitment are two halves of one capability, and both are gated by allowRecruitment. findAgentsByCapability locates a specialist; recruitAgent(agentId, role, reason) brings it in.
A recruit joins from the next round, never mid-round — a roster that changed while a round was running would move the speaker index a paused discussion resumes from, and change the denominator the convergence and unanimity checks already computed for the round in flight. The recruitment is recorded as a FACILITATION transcript entry naming the recruiter, the recruit, the role and the reason, so the rest of the team can see why the roster changed.
Recruitment is refused, with an actionable message, when the agent is not deployed, is already a member, is the recruiter itself, when maxRecruitedAgentsPerDiscussion is reached, or when the discussion's owner may not use the agent. A recruit speaks as that owner and at their cost, so with workspaces enforced it has to be an agent the owner could have started a conversation with: their own, one shared with them directly, or a published one. Team shares do not count here — the member turn runs without the owner's token, so their team memberships are unknown. An owner who holds eddi-admin on the request that drives the turn is admitted, as they are everywhere else. The access check runs before the deployment check, so a refusal never reveals whether an off-limits agent is deployed.
Recruits are never torn down. They are pre-existing deployed agents the discussion borrowed, so TeardownAgentTool and end-of-discussion cleanup leave them alone — undeploying one would take it away from every other conversation using it. Only agents the discussion created are cleaned up.
DynamicAgentConfig
Guardrails for dynamic agent creation are configured per-group via AgentGroupConfiguration.dynamicAgents:
enabled
false
Master switch for dynamic agent capabilities
allowCreation
false
Allow creating new agents (vs. only recruiting existing)
allowRecruitment
false
Allow recruiting already-deployed agents into the discussion
allowDelegation
true
Allow delegating sub-tasks to other agents
maxCreatedAgentsPerDiscussion
5
Cap on new agents created per discussion — counted across all members, not per member; a torn-down agent frees its slot
maxRecruitedAgentsPerDiscussion
10
Cap on recruited agents per discussion
delegationTimeoutSeconds
60
How long a delegating agent waits for its delegate's turn. Non-positive falls back to the default
maxDelegationsPerTask
3
Cap on delegations per task
maxDelegationDepth
3
How deep a delegation chain may nest before it is refused
allowedDelegationTargets
null (any deployed agent)
Whitelist of agent ids a member may delegate to
lifecyclePolicy
EPHEMERAL
EPHEMERAL, KEEP_DEPLOYED, UNDEPLOY_ONLY, or AGENT_DECIDES
inheritParentModel
true
Created agents inherit the parent's provider, model and API-key reference — see below
allowedProviders
null (any)
Whitelist of LLM providers
allowedModels
null (any)
Per-provider model whitelist
Dynamic agents are tracked in GroupConversation.dynamicMembers, createdAgentIds, and retainedAgentIds.
What the tools may touch.
teardown_agentundeploys (and optionally deletes) only an agent that is both in the conversation's created list and carries adynamicOriginin its own configuration naming the calling conversation or its discussion.create_sub_agentstamps that marker on the agent's first version; an agent a person built has none, so it can never be torn down by a tool. Only the engine writes the marker: an agent update (PUT, a merge import, an upgrade) keeps the stored value whatever the body says, and a duplicate or a ZIP import that creates a new agent drops it.End-of-discussion cleanup applies the same marker: it deletes only agents whose
dynamicOriginnames this discussion, leaves agents marked for another one alone, and only undeploys — never deletes — an agent with no marker (one created before markers existed, or never created bycreate_sub_agent).retain_agentflags persist across turns;unretain_agentremoves them for good.converse_with_agentcontinues only a conversation owned by the same user the calling conversation belongs to (a conversation of another user, or one that records no owner, is refused). Every delegation — a new conversation or a continued one — requires that user to be allowed to use the target agent, the same rule as recruitment above; agents this conversation or its discussion created are exempt — "created" meaning tracked and carrying adynamicOriginthat names this conversation or its discussion.
Model and credential inheritance
createSubAgent takes provider and model as optional arguments. What is not given is inherited from the creating agent, in this order:
Provider — the parent's, when
inheritParentModelis on.Model — the parent's, but only if the effective provider is still the parent's. An anthropic parent creating an
ollamasub-agent does not hand it a Claude model name.API key — the parent's
${vault:...}reference, again only when the provider matches, because a vault reference names one provider's secret.
Only a vault reference is inherited, never a plaintext key. When the vault is unconfigured, vaultApiKey falls back to storing the key in clear; copying such a value into a second config would multiply that fallback's blast radius. A parent whose key is plaintext therefore inherits nothing, and creation fails with the ordinary "API key is required" error.
The allow-lists are checked against the effective provider and model — the values after inheritance and defaults are applied, not the raw arguments. Omitting provider does not skip allowedProviders. For allowedModels:
Provider named (or inherited): an absent or empty entry for that provider means no restriction for that provider.
No provider named: the model is about to be paired with the default provider, so that provider must itself appear in
allowedModels. A config restrictingopenaitogpt-4o-miniwill not silently build an anthropic agent runninggpt-4o-mini.
Tenant Quota Enforcement
If tenant quotas are enabled, QuotaExceededException is propagated regardless of the group's onAgentFailure policy — quota violations always abort the discussion to prevent runaway resource consumption.
Protocol Configuration
agentTimeoutSeconds
Any positive integer
180
onAgentFailure
SKIP, RETRY, ABORT
SKIP
maxRetries
0+
2
onMemberUnavailable
SKIP, FAIL
SKIP
maxTurns
Any positive integer (0 or negative → default)
50
maxCostPerDiscussion
Dollars, or null for unlimited
null
onCostExceeded
SYNTHESIZE_NOW, ABORT
SYNTHESIZE_NOW
The two ceilings are different instruments. maxTurns bounds how many member turns a discussion may spend — it is the structural stop that keeps a recruiting, extending, re-planning discussion finite. maxCostPerDiscussion bounds dollars, which is what actually differs between a cheap 40-turn round table and an expensive 8-turn one with large contexts. Hitting the cost ceiling under SYNTHESIZE_NOW skips the remaining discussion phases and jumps straight to synthesis, so the caller still gets an answer for what was already spent; ABORT fails the discussion instead. Every LLM call in the discussion is attributed, including the facilitator's, the convergence judge's, and the summarizer's.
The defaults above apply when the
protocolblock is omitted entirely, not only when an individual setting is. Nothing backfills a stored config, so a group saved without aprotocolblock runs on exactly these values.
Timeout guidance: 180s covers thinking models (e.g.
claude-sonnet-5) and synthesis phases comfortably. For tool-calling agents with multiple tool loops, consider300–600. The timeout is per agent turn, not per phase.
REST API
POST
/groupstore/groups
Create group config
GET
/groupstore/groups/descriptors
List group configs (filter, index, limit)
GET
/groupstore/groups/{id}
Read group config
PUT
/groupstore/groups/{id}
Update group config
DELETE
/groupstore/groups/{id}
Delete group config
GET
/groupstore/groups/styles
List discussion styles
POST
/groups/{groupId}/conversations
Start discussion
GET
/groups/{groupId}/conversations/{id}
Read transcript
GET
/groups/{groupId}/conversations
List conversations
DELETE
/groups/{groupId}/conversations/{id}
Delete (artifacts + ephemeral agents; members ended)
GET
/groupstore/groups/jsonSchema
JSON schema for the group config
POST
/groups/{groupId}/conversations/stream
Start discussion, stream events over SSE
POST
/groups/{groupId}/conversations/{id}/followup
Ask one member a follow-up
POST
/groups/{groupId}/conversations/{id}/continue
Continue with a new question (same transcript)
POST
/groups/{groupId}/conversations/{id}/continue/stream
Continue, streaming
POST
/groups/{groupId}/conversations/{id}/close
Close the conversation to further rounds
POST
/groups/{groupId}/conversations/{id}/cancel
Cancel a running discussion
POST
/groups/{groupId}/conversations/{id}/approve
Approve/reject a HITL pause. Returns as soon as the decision is recorded — the resumed run continues asynchronously, so the response typically shows IN_PROGRESS; poll the conversation or use /approve/stream
POST
/groups/{groupId}/conversations/{id}/approve/stream
Approve and stream the resumed run
POST
/groups/{groupId}/conversations/{id}/human-input
Submit a HUMAN member's turn (I6). Asynchronous like /approve: the discussion resumes in the background (IN_PROGRESS)
GET
/groups/{groupId}/conversations/{id}/approval-status
Pause coordinates (detail=full for approvers)
GET
/groups/{groupId}/conversations/pending-approvals
This group's discussions awaiting a decision
GET
/groups/pending-approvals
Every group discussion awaiting a decision, across all groups
GET
/groupstore/templates
List preset templates (I10)
GET
/groupstore/templates/{templateId}
Read one template
POST
/groupstore/templates/{templateId}/instantiate
Create a group from a template
GET
/groupstore/groups/{id}/workspace
Read the standing-team workspace (I13)
GET
/groupstore/groups/{id}/workspace/backlog
Read the team backlog
POST
/groupstore/groups/{id}/workspace/backlog
File a backlog task
POST
/groupstore/groups/{id}/workspace/cadences
Add a cron cadence (409 if the workspace keeps changing under three revision-checked attempts; the schedule it created is deleted again)
DELETE
/groupstore/groups/{id}/workspace/cadences/{cadenceId}
Remove a cadence, then its schedule (409 as above; cadence and schedule then both stay)
SSE Events
POST /groups/{groupId}/conversations/stream (and the continue/approve streaming variants) emit these events. A client that only cares about the answer can watch group_complete; a UI rendering the discussion live wants the speaker and phase pairs.
group_start
The discussion begins (carries group id and question)
phase_start / phase_complete
A phase opens / closes
round_start
A continuation round (round 2+) of the whole discussion begins — see POST .../continue. Phase repeats fire phase_start instead
speaker_start / speaker_complete
A member's turn opens / closes. complete carries the content, or — when the turn produced none — no content and an outcome of TIMEOUT, SKIPPED or ERROR. Every started turn is closed, including a parallel member released by the batch deadline
token
Incremental token from a streaming member turn
synthesis_start / synthesis_complete
The synthesis phase opens / closes
convergence_checked / convergence_reached
A convergence judge ran / declared the phase converged (I2)
decision_reached
A DecisionRecord was set — vote tally, debate verdict, or negotiation agreement (I14/I11)
task_plan_created / task_verified
TASK_FORCE planned its backlog / verified a task
retro_recorded
A RETRO phase finished, with the number of lessons stored (I8)
artifact_updated
A shared artifact was created or revised (I17)
human_input_requested
A HUMAN member's turn is pending (I6)
awaiting_approval / hitl_resume
A HITL pause was committed / released
member_pause_skipped
A member turn was skipped rather than paused
cancelled
The discussion was cancelled
group_complete
Terminal — carries the final state and the synthesized answer
group_error
Terminal failure, with a curated (never raw) message
MCP Tools
Group-related tools live in two classes: the group tools below, plus six group-scoped tools provided by the HITL tool set (marked) — three that act on a pause and three that find one. Both are exposed on the same MCP server; the split is internal.
describe_discussion_styles
Rich descriptions of all styles
list_groups
List group configs
read_group
Read group config
create_group
Create group (name, members, style, roles, types)
update_group
Update group config JSON
delete_group
Delete group config
discuss_with_group
Start discussion, return transcript
read_group_conversation
Read conversation transcript
list_group_conversations
List past discussions for a group, with state and timestamps
start_group_discussion
Start a discussion asynchronously (returns immediately). Poll with read_group_conversation
delete_group_conversation
Delete a group conversation. Artifacts and ephemeral agents are deleted; member conversations are ended, not deleted
followup_with_member
Ask a single member a follow-up on a finished discussion
continue_group_discussion
Continue a discussion with a new question
close_group_conversation
Close a conversation to further rounds
list_group_pending_approvals
List one group's discussions awaiting a decision — HITL tool set
list_all_group_pending_approvals
The cross-group approval inbox — HITL tool set
get_group_approval_status
Pause coordinates for one discussion — HITL tool set
submit_group_human_input
Submit a HUMAN member's turn (I6) — HITL tool set
approve_group_phase
Approve or reject a HITL pause — HITL tool set
cancel_group_discussion
Cancel a running discussion — HITL tool set
add_team_task
File a task on a standing team's backlog (I13)
list_team_backlog
Read a standing team's backlog
list_group_templates
List the preset group templates (I10)
create_group_from_template
Create a group from a template + role assignments
Slack Integration
Group discussions integrate natively with Slack. See slack-integration.md for full setup instructions.
UX Pattern: Header + Thread
All discussion styles use the same rendering pattern in Slack:
Start Banner — posted in the user's thread with style name, agent count, and question
Agent Headers — each agent's first contribution is a channel-level message with a short preview
Full Content — the complete response is posted as a thread reply under the agent's header
Peer Feedback — feedback threads under the target agent's header message
Revisions — revised contributions thread under the agent's own header
Synthesis — moderator's synthesis gets its own channel-level header + thread
Discussion Styles in Slack
ROUND_TABLE
Each agent posts → Moderator synthesizes
PEER_REVIEW
Agents post → Critiques thread under targets → Revisions thread under own → Synthesis
DEVIL_ADVOCATE
Agent posts → Challenger threads challenges → Agent threads defense → Synthesis
DEBATE
PRO agent posts → CON agent posts → Rebuttals thread under opponents → Judge synthesizes
DELPHI
Round 1 agents post → Round 2 agents post (convergence) → Synthesis
TASK_FORCE
Moderator posts plan → Agents post task results → Verifiers thread under targets → Synthesis
Trigger Keywords
Configure trigger keywords in ChannelIntegrationConfiguration to route to specific groups:
Follow-up Conversations
After a discussion, users can reply in any agent's thread to ask follow-up questions. The system injects the agent's discussion context (contribution + peer feedback received) into the prompt for a contextual response.
Not yet supported
Known bounds, so they are not discovered at runtime:
Member-level tool approval
A member agent's hitlConfig.toolApprovals does not gate inside a group. The framework auto-rejects the gated call (system:group) and the member produces a tool-less contribution — see hitl.md → Group members. inGroupTurns: "INBOX" is reserved and rejected at save time.
Nested pauses
A sub-group that pauses for approval or a human turn is cancelled and its member turn recorded SKIPPED. Nested HITL is not supported.
Groups over the OpenAI-compatible API
/v1 exposes deployed agents as models. A group is not addressable there; use the REST or MCP surfaces.
Groups over A2A
An A2A peer can invoke an agent, not a group.
Cross-node tool availability
The task, artifact and recruit tools resolve the running discussion from an in-process registry. Member turns always run in the same JVM as their orchestrator, so this holds — but a discussion is only "live" on the node running it.
Configuration
A sub-agent that inherits a parent's vault reference must itself be granted that secret, or — under the default eddi.vault.grant-enforcement=enforce — it will not deploy. See secrets-vault.md → Agent Grants.
Last updated
Was this helpful?