@theokit/plugin-copilot
v0.1.0
Published
AI Copilot pattern for TheoKit — defineCopilot factory + AgentRoomMember (P#9 RoomMember) + CopilotRuntime + React hooks family + <CopilotChat /> composição theo-ui composites. Differentiator: copilot is presence-visible to other users (multi-user awarene
Downloads
92
Maintainers
Readme
@theokit/plugin-copilot
AI Copilot pattern for TheoKit —
defineCopilotfactory +AgentRoomMember(P#9 RoomMember) +CopilotRuntimeorchestrator + React hooks family +<CopilotChat />composição. Form 4 Hybrid per planp11-plugin-copilotv1.0.
Differentiator from CopilotKit: the copilot is a first-class participant in the realtime room — every human in the room sees the copilot's name, avatar, color, and typing status in the presence Map. Multiple copilots can coexist in the same room with policy-driven dispatch (first-wins / round-robin / all / custom function).
Integration plugin — composes @theokit/sdk Agent + @theokit/plugin-realtime (P#9) + optional @theokit/plugin-rate-limit (P#10) + opt-in @theokit/plugin-canvas + @theokit/plugin-voice + opt-in @theokit/ui composites. Structural type mirrors avoid hard imports of peers — the plugin compiles standalone and resolves peers at runtime.
Install
pnpm add @theokit/plugin-copilot @theokit/sdk @theokit/plugin-realtime theokit
# Optional rate-limit guard (P#10):
pnpm add @theokit/plugin-rate-limit
# Optional Zod schemas for room.presence / room.broadcast:
pnpm add zod
# Optional React peer for the /react sub-path:
pnpm add react react-dom
# Optional theo-ui composites (only when using <CopilotChat /> or
# the headless hooks alongside @theokit/ui):
pnpm add @theokit/ui
# Opt-in capability integrations:
pnpm add @theokit/plugin-voice # voice STT/TTS
pnpm add @theokit/plugin-canvas # canvas artifact emissionQuick start
// app/copilots/support.ts
import { defineCopilot } from "@theokit/plugin-copilot";
import { z } from "zod";
export default defineCopilot({
id: "support-bot",
room: {
id: "support-room",
presence: z.object({
name: z.string().optional(),
cursor: z.tuple([z.number(), z.number()]).optional(),
}),
broadcast: z.object({
kind: z.enum(["question", "answer", "tool-call"]).optional(),
text: z.string().optional(),
}),
},
agent: {
name: "SupportBot",
model: "openai/gpt-4o-mini",
systemPrompt: "You are SupportBot. Be concise and helpful.",
},
identity: {
name: "Support Bot",
avatar: "/avatars/support.png",
color: "#7c3aed",
},
triggers: [
{ on: "broadcast:question", action: "respond" },
{ on: "presence:idle", action: "suggest", idleMs: 30_000 },
],
budget: {
perRoom: {
perRequestUsd: 0.01,
dailyUsd: 1.00,
},
},
});// server bootstrap
import { Agent } from "@theokit/sdk";
import { createMemoryRealtimeProvider } from "@theokit/plugin-realtime";
import { CopilotRuntime } from "@theokit/plugin-copilot";
import supportCopilot from "./copilots/support.js";
const provider = createMemoryRealtimeProvider();
// Bridge SDK Agent (static methods) to CopilotAgentLike (instance shape).
const agent = {
async *streamObject(opts: {
schema: unknown;
prompt: string;
model: string | { id: string };
systemPrompt?: string;
}) {
const modelSel = typeof opts.model === "string" ? { id: opts.model } : opts.model;
const sys = opts.systemPrompt ?? "";
const fullPrompt = sys ? `${sys}\n\n${opts.prompt}` : opts.prompt;
const result = await Agent.prompt(fullPrompt, {
model: modelSel,
apiKey: process.env.OPENROUTER_API_KEY ?? "",
local: { settingSources: [] },
providers: {
routes: [{ capability: "chat", provider: "openrouter" }],
fallback: ["openrouter"],
},
});
if (result.status !== "finished") {
throw new Error(`Agent failed: ${JSON.stringify((result as { error?: unknown }).error)}`);
}
const text = typeof result.result === "string" ? result.result : "";
yield { type: "partial", partial: { text }, attempt: 0 } as const;
yield { type: "complete", object: { text } } as const;
},
};
const runtime = new CopilotRuntime({
provider,
agent,
copilots: [supportCopilot],
estimatedCostPerInvocationUsd: 0.001,
});
await runtime.activate("support-bot");React composição
// app/page.tsx
import { CopilotProvider, CopilotChat, useCopilot, useCopilotPresence } from "@theokit/plugin-copilot/react";
import { provider, runtime } from "./bootstrap";
export default function Page() {
return (
<CopilotProvider
roomId="support-room"
copilotId="support-bot"
provider={provider}
localConnectionId="alice"
runtime={runtime}
>
<CopilotChat />
</CopilotProvider>
);
}Or use the headless hooks family for full theme control:
import {
useCopilotMessages,
useCopilotPresence,
useCopilotTyping,
useCopilotReadable,
useCopilotTool,
} from "@theokit/plugin-copilot/react";
function MyCustomChat() {
const messages = useCopilotMessages();
const presence = useCopilotPresence(); // human peers (filtered)
const typing = useCopilotTyping(); // {copilotId, progress?} | null
useCopilotReadable("currentPage", { url: "/dashboard" }); // broadcasts context to copilot
useCopilotTool({ name: "create-task", schema: { /* JSON schema */ } }); // exposes a tool to copilot
// …render however you want
}Triggers — when the copilot acts
Three declarative trigger families per ADR D3:
| Trigger | When it fires | Action types |
|---|---|---|
| broadcast:<event> | Any human broadcasts a frame with event === <event> | respond / execute-tool |
| presence:idle | No human activity in the room for idleMs | suggest |
| custom | Custom filter function returns true for a frame | respond / suggest / execute-tool |
The CopilotRuntime filters out frames originating from any connection id starting with copilot: BEFORE evaluating triggers (EC-4 + EC-8 — cost-runaway and copilot impersonation guards).
Multi-copilot per room — dispatcher policy (ADR D6)
When multiple copilots are registered in the same room, the dispatcher field of each CopilotDescriptor (or defaultDispatcher on the runtime) decides who responds:
| Policy | Behaviour |
|---|---|
| "first-wins" (default) | Only the first registered copilot in the room responds. Prevents cost runaway by default. |
| "round-robin" | Cursor cycles through copilots one frame at a time. |
| "all" | Every copilot in the room responds to every triggering frame. Opt-in only — expensive. |
| (copilots, frame) => string[] | Custom function returns the array of copilot ids that should respond. |
Budget integration — opt-in cost guard (ADR D7)
Each copilot can declare budget.perRoom: { perRequestUsd, dailyUsd, monthlyUsd }. Before each agent invocation, the runtime runs a preflight against the rolling daily + monthly windows. On budget exceeded, the copilot broadcasts a typed budget-exceeded frame to the room instead of invoking the agent:
{
"type": "broadcast",
"connectionId": "copilot:support-bot",
"event": "budget-exceeded",
"payload": { "message": "Per-request budget exceeded: $0.05 limit, would consume $0.10", "code": "budget_per_request_exceeded" }
}runtime.getUsage(copilotId) returns { dailyUsedUsd, monthlyUsedUsd } for theo-ui usage-meter integration.
Custom provider (Liveblocks / PartyKit / Redis / TheoCloud)
import { defineCopilotRealtimeProvider } from "@theokit/plugin-copilot";
const myProvider = defineCopilotRealtimeProvider({
async joinRoom(roomId, conn, initialPresence) { /* ... */ },
async leaveRoom(roomId, connectionId) { /* ... */ },
async broadcast(roomId, connectionId, event, payload) { /* ... */ },
async updatePresence(roomId, connectionId, patch) { /* ... */ },
async getPresence(roomId) { return {}; },
subscribeRoom(roomId, listener) { return () => {}; },
});The helper validates that all 6 required methods are present at construction.
Security threats addressed
| Threat | Mitigation |
|---|---|
| Cost runaway via copilot loop (copilot A triggers copilot B which triggers copilot A) | TriggerEvaluator filters out frames where connectionId.startsWith("copilot:") BEFORE matching triggers (EC-4). Default dispatcher "first-wins" further bounds same-room cost. |
| Copilot impersonation by a malicious human client | The copilot: connection-id prefix is reserved; humans cannot claim a copilot:* connection id when joining via the realtime layer (EC-8 — enforced by the consumer's wire layer; the copilot runtime never accepts a frame from a copilot:* connectionId as a trigger source). |
| Per-request cost spike (large prompt, model hallucination loop) | Optional budget.perRoom.perRequestUsd preflight — exceeds emit typed budget-exceeded frame instead of invoking the agent. |
| Rolling daily / monthly cost overrun | budget.perRoom.{dailyUsd, monthlyUsd} rolling windows reset at UTC day / month boundaries. |
| Tool/knowledge registry injection via React hooks | useCopilotReadable / useCopilotTool broadcast register / deregister events scoped to the local connection; the copilot runtime decides whether to use them — never assumes trust. |
| Trigger ReDoS via malicious broadcast: event names | Trigger event names are matched via exact-string equality (no regex). Custom-filter triggers run in the consumer's process — consumer responsibility. |
Comparison vs CopilotKit
| Feature | CopilotKit | @theokit/plugin-copilot |
|---|---|---|
| Frontend SDK | ✓ extensive (React + custom) | ✓ React hooks family + <CopilotChat /> |
| Agent runtime | bridge via runtime tier (AG-UI) | direct binding to @theokit/sdk Agent.streamObject / Agent.prompt |
| Tool registration | useCopilotAction | useCopilotTool (registers via broadcast event) |
| Context registration | useCopilotReadable | useCopilotReadable (registers via broadcast event) |
| Multi-user awareness | ✗ copilot is invisible to other users | ✓ copilot is a RoomMember — visible in presence Map with name + avatar + color + typing |
| Multi-copilot in same room | ✗ | ✓ dispatcher policy: first-wins / round-robin / all / custom fn |
| Budget per-room | ✗ | ✓ perRequestUsd + dailyUsd + monthlyUsd rolling windows + typed budget-exceeded frame |
| Provider abstraction | ✗ specific runtime | ✓ any P#9 RealtimeProvider (Memory + Yjs + Liveblocks + PartyKit + TheoCloud) |
| Voice (STT + TTS) | ✗ | opt-in via @theokit/plugin-voice peer |
| Canvas (artifacts) | ✗ | opt-in via @theokit/plugin-canvas peer |
License
MIT.
