Agentic AI Atlasby a5c.ai
OverviewWikiGraphFor AgentsEdgesSearchWorkspace
/
GitHubDocsDiscord
iiRecord
Agentic AI Atlas · `@a5c-ai/voice-adapter` — media-governance bridge (draft spec, **audio + video/avatar**)
page:docs-research-voice-governance-bridge-speca5c.ai
Search record views/
Record · tabs

Available views

II.Record viewspp. 1 - 1
overviewarticlejsongraph
III.Related pagespp. 1 - 1
II.
Page reference

page:docs-research-voice-governance-bridge-spec

Reading · 13 min

`@a5c-ai/voice-adapter` — media-governance bridge (draft spec, **audio + video/avatar**) reference

The voice stack splits cleanly:

Pagewiki/docs/research/voice-governance-bridge-spec.mdOutgoing · 0Incoming · 1

`@a5c-ai/voice-adapter` — media-governance bridge (draft spec, **audio + video/avatar**)

**Status:** Draft design spec for a **proposed, not-yet-built** package. Companion to `realtime-voice-agent-stack.md` (architecture, incl. Part II video) and `realtime-agent-gaps.md` (gap register).

**Date:** 2026-06-23.

**One-liner:** the thin TypeScript bridge that lets a realtime media agent call **babysitter-governed tools** over MCP — now covering both the voice path **and** the animated-avatar **video** path on kradle/Jitsi: cosmetic animation runs on a realtime fast path, while consequential visual actions (canvas content, screen-share/VNC, external video metadata) are gated exactly like any sensitive tool, with a replayable audit journal — all out of the audio/animation hot path.

**Reconciled with kradle reality (Appendix E of the architecture doc):** kradle ALREADY has the session/identity CRDs (AgentStack.jitsiConfig, JitsiMeeting, AgentAppearance/AgentVoiceProfile) and the dispatch→sidecar wiring. **§4 below now EXTENDS those existing CRDs rather than minting a parallel VoiceCall** (an earlier draft of this spec proposed a new CRD before the code map; superseded).

1. Why this package exists

The voice stack splits cleanly:

  • **External (LiveKit, Python):** media (WebRTC + SIP), VAD/turn-detection, STT, the conversational LLM turn, TTS. The audio hot path. We do **not** rebuild this.
  • **Internal (this package, TS):** the *governed* tool/decision workflows the agent invokes mid-conversation — decomposition, policy, human approval gates, and a replayable audit journal — powered by the babysitter SDK + genty + kradle.

The bridge is the seam between them. It exists because (per the research) the only safe place to insert a deterministic, fsync-per-step, replay-based governance runtime into a sub-second voice loop is **behind an async tool boundary**, and LiveKit's **native one-line MCP** support makes MCP the cleanest wire for that boundary.

Naming/placement follows the established packages/adapters/* sibling convention (@a5c-ai/<x>-adapter, see channels-adapter).

2. Proposed package layout

Code
packages/adapters/voice/
  src/
    index.ts                  # barrel
    mcp/
      server.ts               # MCP server exposing governed tools to LiveKit
      tools.ts                # GovernedTool registry + schema
      runDriver.ts            # in-process babysitter loop (create→iterate→commit)
    crd/
      voiceCall.ts            # VoiceCall CRD type + schema (kradle)
      voiceCallController.ts  # reconciler (clone of JitsiMeeting controller)
    backends/
      telephony.ts            # channels-adapter backend: inbound call → spawn
    processes/
      governedToolProcess.ts  # babysitter process() skeleton for a governed tool
    types.ts
    __tests__/...
  package.json                # @a5c-ai/voice-adapter, deps: babysitter-sdk, genty-core,
                              #   @modelcontextprotocol/sdk, kradle-core/sdk, triggers/channels-adapter
  tsconfig.json  vitest.config.ts (root-pinned)  README.md  LICENSE
  graph: → packages/atlas/graph/catalog-meta/package-surfaces/voice-adapter.yaml

Dependencies (all in-repo, 6.0.0): @a5c-ai/babysitter-sdk, @a5c-ai/genty-core, @a5c-ai/transport-adapter, @a5c-ai/channels-adapter, kradle core/sdk; external @modelcontextprotocol/sdk. Architecture family: dispatch-core (depends on adapters + babysitter, like channels-adapter).

3. Component 1 — MCP server exposing governed tools

LiveKit connects with one line: mcp_servers=[mcp.MCPServerHTTP(url="http://voice-bridge/mcp")]. Each governed tool is declared once and backed by a babysitter run.

3.1 GovernedTool declaration

ts
interface GovernedTool {
  name: string;                      // MCP tool name, e.g. "issue_refund"
  description: string;               // shown to the conversational LLM
  inputSchema: JSONSchema;           // validated before any run is created
  /** babysitter process entrypoint that governs this tool's workflow */
  process: { importPath: string; exportName: string };
  /** async = return control to the agent immediately (filler), deliver via callback.
   *  sync  = block the MCP call until the run is terminal (only for fast, <~1.5s workflows). */
  mode: 'async' | 'sync';
  /** optional pre-filler spoken while the governed run executes */
  filler?: string;                   // e.g. "Let me take care of that, one moment."
}

3.2 MCP call semantics (async tool — the default)

1. MCP tools/call arrives (issue_refund, {acct, amount}). Validate against inputSchema (reject malformed before any run — no fallback). 2. Create a babysitter run: createRun({ process, inputs: args, runsDir }). Tag it with the voiceCallId (§4) for correlation. 3. **Return immediately** with an MCP result that carries a correlationId + the filler string. LiveKit speaks the filler (ToolFlag.CANCELLABLE); the conversation continues. 4. Drive the run out-of-band in runDriver.ts (§3.3). 5. On terminal/approval-needed, deliver the result back into the LiveKit session — preferred mechanism: a **second MCP tool the agent polls** *or* an MCP **notification / SSE event** the LiveKit worker subscribes to, injected as a developer/tool message → the agent speaks the outcome. (LiveKit-side: an async tool that awaits a future resolved by this callback.)

For mode:'sync' (fast governed workflows only): block the MCP response until terminal; LiveKit treats it as an ordinary synchronous tool. Use sparingly — only when the governed workflow's worst-case latency fits inside the filler-free budget.

3.3 In-process run driver (no subprocess)

runDriver.ts is the loop proven by genty's runInternalOrchestrationPhase:

ts
async function driveRun(runDir: string, resolvers: EffectResolvers): Promise<RunOutcome> {
  for (;;) {
    const it = await orchestrateIteration({ runDir });
    if (it.status === 'completed') return { ok: true, value: it.result };
    if (it.status === 'failed' || it.status === 'process-error') return { ok: false, error: it };
    if (it.status === 'halted') return { ok: false, halted: it };
    if (it.status === 'waiting') {
      for (const action of it.nextActions) {
        if (action.kind === 'breakpoint') {
          // surface approval to the call (DTMF/console/human-agent); resolve when answered
          const decision = await resolvers.approve(action);            // async, may be slow
          await commitEffectResult({ runDir, effectId: action.effectId,
            invocationKey: action.invocationKey, result: { status: 'ok', value: decision } });
        } else {
          // execute the governed sub-tool IN THE HOST (genty lesson: don't make the LLM drive it)
          const value = await resolvers.execute(action);               // tool call / genty agent
          await commitEffectResult({ runDir, effectId: action.effectId,
            invocationKey: action.invocationKey, result: { status: 'ok', value } });
        }
      }
    }
  }
}

resolvers.execute runs the actual sub-tool (a DB write, an API call, or a genty-core agent task with customTools); resolvers.approve routes a breakpoint to the human channel.

4. Component 2 — kradle CRD extensions (EXTEND existing, don't invent)

The code map (Appendix E of the architecture doc) shows kradle already has the session/identity CRDs. We **extend** them — no parallel VoiceCall kind.

**(a) AgentStack.spec.jitsiConfig — add the video capability** (validated in agent-stack-controller.js:223-257):

yaml
spec:
  jitsiCapability: true
  jitsiMeetingProviderRef: { name: ... }
  jitsiConfig:
    role: agent
    participantName: "Aria"
    capabilities:
      audio: publish          # existing
      video: publish          # NEW — gates the avatar/video media plane
    avatarRef: { name: aria-appearance }      # NEW — points at an AgentAppearance
    tools: [send_chat, speak, set_expression, set_posture, play_gesture,    # NEW video tools
            look_at, set_view, draw_canvas, share_surface, send_video_metadata]
    governedTools: [draw_canvas, share_surface, send_video_metadata]        # NEW — which visual tools are babysitter-gated

JitsiCapabilityReady validation extends to: video role may publish, avatarRef resolves, governedTools ⊆ tools.

**(b) AgentAppearance — add the avatar model** (resource-model.js:36; today “avatar generation settings” only):

yaml
spec:
  organizationRef: ...
  renderer: talkinghead          # talkinghead | live2d
  avatarModelUrl: "https://…/aria.glb"     # Ready Player Me GLB (own-licensed)
  visemeSet: oculus              # oculus | arkit
  defaultMood: neutral
  defaultView: upper

Pair with the existing AgentVoiceProfile (TTS provider/voice). **Critical wiring gap (G10):** both are resolved into dispatch identity (agent-dispatch-controller.js:303-309) but never reach the sidecar — thread them through prepareMeetingContext (jitsi-agent-bridge.js:62-99) → meetingContext → createJitsiSidecarContainer env (adapters-client.js:94-118).

**(c) JitsiMeeting.status — add media/session tracking** (jitsi-meeting-controller.js:156-174 tracks only recording.*):

yaml
status:
  media:        { agentTracks: [{ participant, audio: true, video: true, screenshare: false }] }
  transcript:   { live: true, ref: "..." }
  session:      { agents: [{ stackRef, jobRef, phase }] }
  governanceRuns: [{ tool: draw_canvas, runId: 01…, phase: waiting-approval }]   # correlation to babysitter runs

The meeting controller watches babysitter run journals (via this bridge) to populate governanceRuns, making every gated visual/tool decision queryable as control-plane state + a replayable journal.

5. Component 3 — inbound spawn (two paths)

  • **kradle-native (today's path):** an inbound meeting/call → JitsiMeeting + dispatchAutoJoinAgents / manual dispatch (jitsi-meeting-controller.js:190-219, agent-dispatch-controller.js) → createAgentJob attaches the sidecar (adapters-client.js:496-498). Primary flow, already exists; we ride it.
  • **channels-adapter (telephony/SIP):** for PSTN inbound, a new "telephony" backend in channels-adapter's poller/relay/spawner (spawner.ts:148,383) maps an inbound-call event → SessionSpawner.spawn (bounded concurrency + reply back-channel), which requests a kradle dispatch into the room. Outbound calls are initiated via the meeting controller + SIP gateway.

6. Governed-tool process skeleton (babysitter)

processes/governedToolProcess.ts — the process(inputs, ctx) that governs one tool's workflow. **Must be deterministic across replay** (no wall-clock/random branching; non-deterministic results enter only as effect results).

ts
import { defineTask } from '@a5c-ai/babysitter-sdk';

const verifyIdentity = defineTask('verify-identity', (a, t) => ({ kind: 'agent', /* genty agent */ ... }));
const checkPolicy    = defineTask('check-policy',    (a, t) => ({ kind: 'agent', ... }));
const executeRefund  = defineTask('execute-refund',  (a, t) => ({ kind: 'agent', ... }));

export async function process(inputs, ctx) {
  const { acct, amount } = inputs;

  // 1. multi-step decomposition + early validation (cheap effects first)
  const who = await ctx.task(verifyIdentity, { acct });
  if (!who.verified) return { status: 'denied', reason: 'identity-unverified' };

  // 2. policy: PolicyEngine also gates dispatch automatically; this is the explicit business check
  const policy = await ctx.task(checkPolicy, { acct, amount, who });
  if (!policy.allowed) return { status: 'denied', reason: policy.reason };

  // 3. HITL gate — breakpointId prefix drives the posture. `auth.`/`destroy.` force OWNER approval
  //    and cannot auto-approve. Threshold chosen by business rule, evaluated as a deterministic input.
  if (amount >= inputs.approvalThreshold) {
    const ok = await ctx.breakpoint({
      breakpointId: 'auth.refund-over-threshold',
      title: `Approve $${amount} refund for ${acct}?`,
      expert: 'owner', tags: ['voice-call', `call:${inputs.voiceCallId}`],
    });
    if (!ok.approved) return { status: 'denied', reason: 'approval-rejected', response: ok.response };
  }

  // 4. irreversible execution (LiveKit side pairs this with disallow_interruptions())
  const receipt = await ctx.task(executeRefund, { acct, amount, approvedBy: 'owner' });
  return { status: 'done', receipt };  // journal = replayable audit trail of every step above
}

The journal of this run is the per-decision audit record the cascaded pipeline's text trail feeds; auth./destroy. breakpoints guarantee a human gate on sensitive actions; PolicyEngine can hard-deny any sub-effect.

7. End-to-end sequence (async governed tool with approval)

Code
caller speaks ── LiveKit STT ── LLM decides issue_refund($400)
   └─ MCP tools/call → voice-bridge: validate → createRun(governedToolProcess, {acct,$400})
        → return {correlationId, filler:"one moment…"}  (LiveKit speaks filler, convo continues)
   runDriver: iterate → verifyIdentity → checkPolicy → breakpoint(auth.refund-over-threshold)
        → VoiceCall.status.governanceRuns[*].phase = waiting-approval
        → resolvers.approve routes to supervisor (DTMF/console/human-agent)
        → commitEffectResult(approved) → iterate → executeRefund → completed
   callback → inject tool result into LiveKit session → agent: "Done, $400 refunded."

If approval is slow, the agent fills naturally ("still waiting on a supervisor…"); the audio path never blocked on a babysitter fsync/iterate.

8. Latency-budget rules (non-negotiable)

  • Governed tools are **async by default**; only provably-fast workflows use sync.
  • A pre-createRun inputSchema validation is the only synchronous work on the MCP call path.
  • No babysitter orchestrateIteration/commitEffectResult ever runs inside a turn — always in runDriver off the hot path.
  • Filler speech + ToolFlag.CANCELLABLE cover the governance round-trip; disallow_interruptions()/wait_for_playout() wrap only the irreversible execute step (heed issue #4560 — re-assert per step).

8A. Avatar control protocol + the two lanes (video)

The agent controls the character with tool calls mapped onto the renderer (TalkingHead.js) vocabulary. They split by latency/consequence:

LaneToolsPathGoverned?
**Realtime fast path** (must sync to speech, sub-100ms)speak (visemes internal), set_expression(mood), set_posture/play_gesture, look_at, set_viewMCP → **G0 socket → sidecar renderer** directlyno (cosmetic, reversible) — light audit only
**Governed async path** (shows content / shares desktop / emits data)draw_canvas (content), share_surface/share_vnc, send_video_metadata to external sinksMCP **async tool → babysitter run** (filler) → on approval → socket → sidecaryes — policy + auth./destroy. breakpoints + journal

MCP tool surface to add (consumed by the G0 socket-writer; see gaps G16): kradle_speak, kradle_set_expression, kradle_set_posture, kradle_play_gesture, kradle_look_at, kradle_set_view (fast); kradle_draw_canvas, kradle_share_surface, kradle_send_video_metadata (governed). Visemes are **never** a tool — they're driven internally from the TTS clock (architecture doc §II.2).

A governed *visual* process mirrors §6, e.g. share_surface → ctx.task(resolveTarget) → ctx.breakpoint('auth.screen-share') (owner) → ctx.task(startVnc); or draw_canvas with externally-visible content → ctx.task(contentPolicyCheck) → optional breakpoint → emit draw commands.

8B. Sidecar media plane the bridge drives (kradle `jitsi-agent-sidecar`)

The bridge's fast-path and approved governed commands land as IPC actions on the sidecar. Required sidecar work (gaps G0–G8):

  • **G0 (load-bearing):** build the **agent↔sidecar socket client** — today MCP tools only return a {socketPath,command} descriptor and nothing writes it to /tmp/jitsi-agent.sock (mcp-server.js:709-733); without this *no* command (even chat) reaches the sidecar.
  • **Render + publish:** inject a TalkingHead canvas in the headless page; canvas.captureStream() → publish via lib-jitsi-meet setEffect (video); TTS → Web-Audio graph → captureStream() (audio) on the same clock (lipsync). Replace the --use-fake-device placeholder and the audio.js stubs.
  • **New IPC actions:** extend SUPPORTED_ACTIONS (ipc-server.js:4-14) + handleCommand (runtime.js:73-102) with set_expression, set_posture, play_gesture, look_at, set_view, draw_canvas, start_screenshare, send_video_metadata; emit inbound chat events (runtime.js:58-71).
  • **Screen-share:** replace window.open (puppeteer-jitsi-client.js:63-65) with noVNC-canvas compositing or getDisplayMedia → screen track.

8C. Full user flow (create stack → agent → call → interact by text AND video)

1. **Create an AgentStack** in the kradle web **stack-builder** (gap G14 adds the "Meeting / Video" section): toggle modalities (text / voice / video), pick the avatar (AgentAppearance) + voice (AgentVoiceProfile) + the governed tool set; writes jitsiCapability + jitsiConfig.capabilities.video + avatarRef. Reconciled by agent-stack-controller.js → JitsiCapabilityReady. 2. **Create an Agent** (persona) bound to the stack (MCP kradle_create_agent or the identity pages → AgentPersona+AgentDefinition (+AgentAppearance/AgentVoiceProfile)). 3. **Call it** — create a JitsiMeeting; dispatch attaches the sidecar Job (createAgentJob), the headless browser joins as the avatar and (with the media plane built) publishes A/V tracks. The kradle web meeting page already renders the agent's track as a participant tile (jitsi-embedded-meeting.jsx) — no new video component needed. 4. **Interact** — the user joins via the web meeting UI and talks/types. The agent responds with **voice + lipsynced mouth + expressions/posture** on the **fast path**, and can **draw on the canvas / screen-share / send video metadata** via the **governed path** (filler speech while babysitter gates the action). Text chat works both ways (human via the iframe chat; agent via send_chat once G0 lands). Video is active **only if the stack declares capabilities.video** — otherwise the same agent is a text/voice participant.

9. Open implementation questions

1. **Callback transport:** MCP notification/SSE vs an agent-polled check_status tool vs a LiveKit/Jitsi data-channel message — which gives the lowest-friction "result is ready" injection? (Prototype both.) 2. **Breakpoint→human routing in-call:** DTMF capture, a supervisor console, or warm-transfer to a human agent who approves — needs a concrete resolvers.approve implementation per channel. 3. **Babysitter latency envelope:** micro-benchmark create→iterate→commit→iterate on target disk to set the sync-eligible threshold and typical governed-tool wall-clock. 4. **Run/session GC:** TTL + terminal-cleanup reconciliation in the JitsiMeeting controller; orphaned-run sweeping; tearing down the sidecar Job + babysitter runs when the meeting ends. 5. **genty-as-sub-executor vs direct effect resolvers:** when a governed sub-task is itself agentic, run it via genty-core (customTools) vs a plain function — pick per tool. 6. **Multi-tool calls in one turn:** ordering/locking when the LLM emits several governed tool calls at once (babysitter runs are per-tool; JitsiMeeting.status.governanceRuns correlates them). 7. **Fast-path vs governed boundary:** is draw_canvas cosmetic (fast) or content (governed)? Likely per-call classification (e.g. ephemeral cursor vs persistent rendered text) — needs a crisp rule so animation never accidentally blocks on governance. 8. **A/V sync under load:** measure viseme-vs-audio drift on the real sidecar GPU host (architecture doc §II risk X1) before committing to the same-page-audio-clock approach at scale.

Article source

The article body is owned directly by this record.

Related pages

No related wiki pages for this record.

Shortcuts

Open overview
Open JSON
Open graph