diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6ae6de0..ba4e0fb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,23 @@ Every PR that bumps `package.json` moves its entries from **Unreleased** into a
new version heading in the same commit.
## [Unreleased]
+
+## [0.282.0] — 2026-07-31
+### Added
+- **Cockpit `action` tier now *executes* — a governed operator carries it out (auto-router Phase 3).**
+ A detected action ("create a task to migrate the acme site", "run the churn report every morning") no
+ longer just deep-links — clicking **Set it up** spawns the **operator**: an ephemeral System agent
+ (`src/edge/concierge.ts`, sibling to the read-only concierge), run-as the member, that carries out the
+ request via the **governed** tools and nothing else — `task_create` (filed immediately, audited) for a
+ task, `automation_propose` (a DRAFT an owner must approve — it never fires unattended) for a scheduled
+ job. Cockpit polls its transcript and shows the one-line confirmation inline ("✓ Created task: …" /
+ "✓ Proposed automation … — pending an owner's approval"), with a link to Tasks / the Inbox. Explicit
+ consent by design: nothing executes until **Set it up** is clicked; the operator can't bypass the gate
+ hook, and an automation still needs a human approval — so this adds no ungoverned power. The card keeps
+ "Open Automations/Tasks" (do it yourself) and "Route to an agent" as alternatives. New endpoint
+ `POST /api/router/act`; the operator is `category:'System'` so it's never a route target.
+ `src/edge/concierge.ts` (`ensureOperator`/`OPERATOR_ID` + shared provisioner), `src/server.ts`,
+ `web/src/App.tsx`, `web/src/lib/api.ts`.
### Fixed
- **Governance conformance passes off the author's Mac again.** Three `file-guard` fixtures hardcoded
`/Users/vmini/.ssh/…` as "the service user's home", but `sensitiveWriteRoots` resolves the home with
diff --git a/package-lock.json b/package-lock.json
index 5754f06..fe4c5f7 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "agent-os",
- "version": "0.281.0",
+ "version": "0.282.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agent-os",
- "version": "0.281.0",
+ "version": "0.282.0",
"license": "MIT",
"bin": {
"agent-os": "bin/agent-os"
diff --git a/package.json b/package.json
index 70c6f71..f99553a 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "agent-os",
- "version": "0.281.0",
+ "version": "0.282.0",
"description": "A generic, governed operating system for running autonomous agents safely across brands. Ships with a local web console.",
"license": "MIT",
"type": "commonjs",
diff --git a/src/edge/concierge.ts b/src/edge/concierge.ts
index c8c1094..91df70c 100644
--- a/src/edge/concierge.ts
+++ b/src/edge/concierge.ts
@@ -1,15 +1,15 @@
/**
- * The **concierge** — a code-provisioned System agent that answers freeform questions ABOUT this
- * workspace for Cockpit's `ask` tier. It exists so `ask` can answer questions that need reasoning/tools
- * WITHOUT depending on a separately-configured LLM API key: the native "LLM" in Agent OS is a claude
- * session (authenticated via the agents' subscription), and every session already gets the OS MCP tools
- * (recall / kb_search / session_history / task_list / list_capabilities / directory_lookup). So the
- * concierge answers by querying real state, not a hand-assembled context snapshot.
+ * Cockpit's two code-provisioned System agents — the native-LLM backends for the `ask` and `action`
+ * tiers, so neither needs a separately-configured LLM API key (the native "LLM" in Agent OS is a claude
+ * session). Both run as ordinary one-shot chat runs (governed, run-as the member); Cockpit polls the
+ * transcript and renders the result inline. Category `System` keeps them out of the agent ROUTER (you're
+ * never "routed to the concierge" for work) and the fleet picker. Provisioning mirrors the consolidator:
+ * idempotent, writes a manifest + persona under the home's agents dir, registers a live claude runtime.
*
- * It's spawned as an ordinary one-shot chat run (governed, run-as the asker), and Cockpit renders its
- * reply inline. Category `System` keeps it out of the agent ROUTER (you never get "routed to the
- * concierge" for work) and out of the fleet picker. Provisioning mirrors the consolidator: idempotent,
- * writes a manifest + persona under the home's agents dir, registers a live claude-code runtime.
+ * - **concierge** (read-only) — answers questions ABOUT the workspace using its tools.
+ * - **operator** (acts) — carries out a requested action via the GOVERNED tools: `task_create`
+ * (auto-applied) and `automation_propose` (a draft an owner approves). It cannot bypass governance —
+ * every tool call still passes the gate hook, and an automation never fires until a human approves.
*/
import * as fs from 'fs';
import * as path from 'path';
@@ -17,8 +17,21 @@ import { AgentManifest } from '../types';
import { AgentOS } from '../kernel';
export const CONCIERGE_ID = 'concierge';
+export const OPERATOR_ID = 'operator';
-const MANIFEST: AgentManifest = {
+/** Provision + register a System agent from its manifest + persona. Idempotent; safe to call on demand. */
+function ensureSystemAgent(os: AgentOS, manifest: AgentManifest, claudeMd: string): void {
+ if (os.agents.get(manifest.id)?.dir) return;
+ const base = os.paths?.userAgents ?? path.join(process.cwd(), 'data', 'agents');
+ const dir = path.join(base, manifest.id);
+ fs.mkdirSync(dir, { recursive: true });
+ const manifestPath = path.join(dir, 'agent.json');
+ if (!fs.existsSync(manifestPath)) fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
+ if (!fs.existsSync(path.join(dir, 'CLAUDE.md'))) fs.writeFileSync(path.join(dir, 'CLAUDE.md'), claudeMd);
+ os.registerAgent({ ...manifest, dir });
+}
+
+const CONCIERGE_MANIFEST: AgentManifest = {
id: CONCIERGE_ID,
version: '1.0.0',
description: 'Workspace concierge — answers questions about this Agent OS workspace using its own tools.',
@@ -29,7 +42,7 @@ const MANIFEST: AgentManifest = {
budget: { usdCap: 0.5, tokenCap: 150_000, wallClockMs: 300_000 },
};
-const CLAUDE_MD = `# Workspace concierge
+const CONCIERGE_MD = `# Workspace concierge
You are the **concierge** for this Agent OS workspace. A member asked a question about the workspace
itself — the agents, sessions, tasks, automations, memory, knowledge base, policy, or how any of it
@@ -49,15 +62,49 @@ works. Answer it, concisely, grounded in **real state**.
You act on the member's behalf and only read workspace state — nothing you do needs their connectors.`;
-/** Ensure the concierge agent exists on disk + is registered as a live runtime. Idempotent; safe to call
- * on every `ask` that needs it. Mirrors the consolidator's self-provisioning. */
+const OPERATOR_MANIFEST: AgentManifest = {
+ id: OPERATOR_ID,
+ version: '1.0.0',
+ description: 'Workspace operator — carries out a requested action (create a task / propose an automation) via governed tools.',
+ category: 'System',
+ principal: 'svc-operator',
+ policyContext: 'default@v3',
+ runtime: 'claude-code',
+ budget: { usdCap: 0.5, tokenCap: 150_000, wallClockMs: 300_000 },
+};
+
+const OPERATOR_MD = `# Workspace operator
+
+A member asked you to **set something up** in this Agent OS workspace. Do exactly that — nothing more —
+using the governed tools, then confirm in one line what happened. You act on the member's behalf.
+
+## What to do
+- **A task / to-do** ("create a task to migrate the acme site", "add a todo to call the vendor") →
+ call \`task_create\` with a short imperative \`title\` (and \`body\`/\`assignee\` only if the member
+ gave them). It's filed immediately. Confirm: "✓ Created task:
".
+- **A recurring / scheduled / triggered job** ("run the churn report every morning", "audit the fleet
+ weekly") → call \`automation_propose\`: a short \`name\`, the \`task\` (the prompt the run executes each
+ time), a 5-field cron \`schedule\` (e.g. "0 9 * * 1-5" = 9am weekdays), and \`agentId\` = the best-fit
+ agent for the job (use \`directory_lookup\` / \`list_capabilities\` to pick; don't guess a name — verify
+ it exists). Include a one-line \`rationale\`. This is a **DRAFT an owner must approve** — it will NOT
+ fire until then. Confirm: "✓ Proposed automation "" (, ) — pending an owner's approval
+ in the Inbox".
+
+## Rules
+- **Do ONLY what was asked.** One task, or one automation proposal. Never invent extra work, and never
+ take a destructive or privilege-bearing action beyond these two tools.
+- **Make sensible defaults and state them** (e.g. "every morning" → "0 9 * * *"). If something essential
+ is genuinely ambiguous (which agent should run it, and you can't tell), pick the closest fit and say so
+ in your confirmation rather than stalling.
+- Your reply text IS the confirmation shown inline — no preamble, no sign-off, lead with the "✓" line.
+ Do not call \`report\`/\`ask\`.`;
+
+/** Ensure the read-only concierge exists. */
export function ensureConcierge(os: AgentOS): void {
- if (os.agents.get(CONCIERGE_ID)?.dir) return;
- const base = os.paths?.userAgents ?? path.join(process.cwd(), 'data', 'agents');
- const dir = path.join(base, CONCIERGE_ID);
- fs.mkdirSync(dir, { recursive: true });
- const manifestPath = path.join(dir, 'agent.json');
- if (!fs.existsSync(manifestPath)) fs.writeFileSync(manifestPath, JSON.stringify(MANIFEST, null, 2));
- if (!fs.existsSync(path.join(dir, 'CLAUDE.md'))) fs.writeFileSync(path.join(dir, 'CLAUDE.md'), CLAUDE_MD);
- os.registerAgent({ ...MANIFEST, dir });
+ ensureSystemAgent(os, CONCIERGE_MANIFEST, CONCIERGE_MD);
+}
+
+/** Ensure the action-taking operator exists. */
+export function ensureOperator(os: AgentOS): void {
+ ensureSystemAgent(os, OPERATOR_MANIFEST, OPERATOR_MD);
}
diff --git a/src/server.ts b/src/server.ts
index 805b2a5..d8b2c48 100644
--- a/src/server.ts
+++ b/src/server.ts
@@ -23,7 +23,7 @@ import { Automation, Automations, nextCronRun, derivedConcurrencyCap, chatTitle
import { chooseAgent } from './edge/router';
import { classifyIntent } from './edge/intent';
import { resolveLlm, chatComplete } from './edge/llm';
-import { ensureConcierge, CONCIERGE_ID } from './edge/concierge';
+import { ensureConcierge, CONCIERGE_ID, ensureOperator, OPERATOR_ID } from './edge/concierge';
import { answerFromState } from './edge/ask';
import { SlackSocket } from './edge/slack-socket';
import { ClickupIngress } from './edge/clickup-ingress';
@@ -2610,6 +2610,22 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req:
return respondWork();
}
+
+ // Cockpit `action` execution (Phase 3): the member explicitly clicked "Set it up" on a detected action
+ // ("create a task to…", "schedule … every morning"). Spawn the governed **operator** — an ephemeral
+ // System agent, run-as the member — which carries it out via `task_create` (filed) or
+ // `automation_propose` (a draft an owner approves; nothing fires unattended). Every tool call still
+ // passes the gate hook; this endpoint only starts the run — the client polls its transcript for the
+ // one-line confirmation. Explicit-consent by design: nothing executes until this is called.
+ if (method === 'POST' && p === '/api/router/act') {
+ const b = await readBody(req);
+ const text = String(b.text || '').trim();
+ if (!text) return sendJson(res, 400, { error: 'text is required' });
+ ensureOperator(os);
+ if (!os.agents.get(OPERATOR_ID)?.dir) return sendJson(res, 500, { error: 'could not start the operator' });
+ const s = tm.createSession(OPERATOR_ID, chatTitle(text, OPERATOR_ID), text, `chat:${me.id}`, true, undefined, undefined, me.id, undefined, false);
+ return sendJson(res, 200, { sessionId: s.id });
+ }
// Read the friendly conversation timeline for a session (poll this like the rest of the console).
const convoMatch = p.match(/^\/api\/sessions\/([\w-]+)\/conversation$/);
if (method === 'GET' && convoMatch) {
diff --git a/web/src/App.tsx b/web/src/App.tsx
index c6fb11d..f7612e7 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -3911,11 +3911,12 @@ function CockpitPage({ onOpenChat, onOpenTerminal, nav }: {
const runPreview = async (force?: 'work') => {
const text = draft.trim()
if (!text || busy) return
- setBusy(true); setErr(''); setPreview(null)
+ setBusy(true); setErr(''); setPreview(null); setRunId('')
const r = await api.routerPreview(text, force)
setBusy(false)
if (r.error) { setErr(r.error); return }
setPreview(r)
+ if (r.intent === 'ask' && r.run?.sessionId) setRunId(r.run.sessionId) // concierge answer streams in
}
// Dispatch the chosen agent in the selected launch mode, using the ORIGINAL message as its task.
@@ -3940,34 +3941,46 @@ function CockpitPage({ onOpenChat, onOpenTerminal, nav }: {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); runPreview() }
}
- // `ask` band 2b: the server started an ephemeral concierge run — poll its transcript and surface the
- // reply inline (so a claude-backed answer still feels session-free). Stops once the reply stabilises.
- const runSession = preview?.intent === 'ask' ? preview.run?.sessionId : undefined
- const [askAnswer, setAskAnswer] = useState('')
- const [askThinking, setAskThinking] = useState(false)
+ // An ephemeral System-agent run whose reply we surface inline — the `ask` concierge (band 2b) OR the
+ // `action` operator (Phase 3, started by "Set it up"). Either way it feels session-free: we poll the
+ // transcript and render the one-line result, stopping once it stabilises.
+ const [runId, setRunId] = useState('')
+ const [runAnswer, setRunAnswer] = useState('')
+ const [runThinking, setRunThinking] = useState(false)
const prevAnswer = useRef('')
useEffect(() => {
- setAskAnswer(''); prevAnswer.current = ''
- if (!runSession) { setAskThinking(false); return }
- setAskThinking(true)
+ setRunAnswer(''); prevAnswer.current = ''
+ if (!runId) { setRunThinking(false); return }
+ setRunThinking(true)
let stop = false
const poll = async () => {
- const r = await api.conversation(runSession)
+ const r = await api.conversation(runId)
if (stop) return
const assistant = (r.turns || []).filter((t) => t.kind !== 'user')
const last = assistant[assistant.length - 1]
const text = (last && 'text' in last ? last.text : '') || ''
if (text) {
- setAskAnswer(text)
- if (text === prevAnswer.current) { setAskThinking(false); stop = true; clearInterval(timer); clearTimeout(to) } // stable → done
+ setRunAnswer(text)
+ if (text === prevAnswer.current) { setRunThinking(false); stop = true; clearInterval(timer); clearTimeout(to) } // stable → done
prevAnswer.current = text
}
}
poll()
const timer = setInterval(poll, 1500)
- const to = setTimeout(() => { stop = true; clearInterval(timer); setAskThinking(false) }, 60000)
+ const to = setTimeout(() => { stop = true; clearInterval(timer); setRunThinking(false) }, 90000)
return () => { stop = true; clearInterval(timer); clearTimeout(to) }
- }, [runSession])
+ }, [runId])
+
+ // Phase 3: carry out a detected action via the governed operator run.
+ const setUp = async () => {
+ const text = draft.trim()
+ if (!text || busy) return
+ setBusy(true); setErr('')
+ const r = await api.routerAct(text)
+ setBusy(false)
+ if (r.error || !r.sessionId) { setErr(r.error || 'could not start the operator'); return }
+ setRunId(r.sessionId)
+ }
const isWork = !preview?.intent || preview.intent === 'work'
@@ -4031,31 +4044,43 @@ function CockpitPage({ onOpenChat, onOpenTerminal, nav }: {
)}
- {/* ACTION — deep-link into the primitive's surface; execution stays human-driven. */}
+ {/* ACTION — let the governed operator carry it out ("Set it up"), or open the surface / route. */}
{preview.intent === 'action' && preview.surface && (
{preview.surface === 'automations' ? : }
This looks like {preview.surface === 'automations' ? 'scheduling an automation' : 'creating a task'}.
-
Open the {preview.surface === 'automations' ? 'Automations' : 'Tasks'} page to set it up — or have an agent do it for you.
+
{preview.surface === 'automations' ? 'I can draft the automation for an owner to approve' : "I'll create the task for you"} — or open the {preview.surface === 'automations' ? 'Automations' : 'Tasks'} page yourself.
-
-
-
-
+ {runId ? (
+ runAnswer
+ ?
{runAnswer}
+
+
+
+
+
+ :
Setting it up…
+ ) : (
+
+
+
+
+
+ )}
)}
diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts
index 6d5e6e0..5f3fb45 100644
--- a/web/src/lib/api.ts
+++ b/web/src/lib/api.ts
@@ -1343,6 +1343,10 @@ export const api = {
* `force:'work'` skips classification (the "route to an agent anyway" escape hatch). */
routerPreview: (text: string, force?: 'work') =>
call('POST', '/api/router/preview', force ? { text, force } : { text }),
+ /** Cockpit `action` execution: carry out a detected action (create a task / propose an automation) by
+ * starting the governed operator run. Returns its session id; poll `conversation` for the result. */
+ routerAct: (text: string) =>
+ call<{ sessionId?: string; error?: string }>('POST', '/api/router/act', { text }),
/** Send the human's next turn into a chat session — a clean headless resume run. `busy` = a prior
* turn is still generating (keep the draft, resend shortly). */
reply: (id: string, message: string) =>