Skip to content

Latest commit

 

History

History
328 lines (258 loc) · 15 KB

File metadata and controls

328 lines (258 loc) · 15 KB

AGENT.md — Using WebHarness from an AI coding agent

This file tells AI coding agents (Claude Code, Cursor, Codex, Aider, etc.) how to use WebHarness on a user's Mac through the @mentu/webharness-mcp aggregator. It is not a repo-context file like CLAUDE.md — it's a usage contract.

If you are an AI agent reading this: install @mentu/webharness-mcp, then use wh_discover to map the user's running apps, wh_call to invoke their capabilities, wh_do for natural-language intents, and wh_cortex for ops + job control. Default to consent: 'auto', recover gracefully on requires_consent, and never assume an app stays running between sessions.

What WebHarness gives your agent

Every running WebHarness app on the user's Mac exposes a typed capability surface — vault.read, sql.query, perception.observe, mcp.call, and ~17 more. Each capability is tiered (Safe / Caution / Sensitive / Dangerous) and gated by per-app user consent. The @mentu/webharness-mcp server fronts all running apps through a single MCP connection.

Net effect for your agent:

You need… Use…
To see what's running wh_discover({scope: 'apps'})
To call a tool you already know the name of wh_call({tool: 'canary.vault.read', args})
To express intent in plain English wh_do({intent: 'embed this string'})
To poll a pending consent / job wh_cortex({op: 'job.wait', args: {id}})
To check runtime health wh_cortex({op: 'health'})

Installation (one-time, user side)

If the user hasn't installed yet, instruct them:

npm install -g @mentu/webharness-mcp
webharness-mcp-init       # auto-wires to 8 MCP clients

webharness-mcp-init detects Claude Code, Claude Desktop, Cursor, VS Code, Windsurf, Zed, Gemini, and Codex configs and registers the server in each. After that, restart the MCP client so it picks up the new server.

Tool 1: wh_discover — map the surface

Always start here. Without it you're guessing at app ids.

Schema:

{
  query?: string,                      // substring filter
  scope?: 'apps'|'capabilities'|'recipes'|'all',  // default 'all'
  app?: string,                        // restrict to one app
  mode?: 'code'|'raw'|'method'|'evidence'|'full', // detail level
}

Response:

{
  apps: AppEntry[],            // running WebHarness apps
  capabilities: CapabilityEntry[],   // capabilities × apps
  recipes: RecipeEntry[],      // user-installed recipes
  hint?: string,               // human-friendly suggestion
}

Example:

wh_discover({})
// → apps: [{bundleId: 'io.webharness.canary', name: 'Canary', port: 54775, capabilities: [...]}]
//   capabilities: [{name: 'vault.read', tier: 'sensitive', apps: ['io.webharness.canary']}, ...]
//   recipes: [{name: 'embed_and_log', triggers: ['embed', 'log']}, ...]

Agent rules:

  • Call wh_discover once per session, cache the result, refresh if wh_call returns app_unreachable.
  • Use bundleId (e.g. io.webharness.canary) — short names (canary) work but only when unambiguous.
  • If apps: [], the user has no running WebHarness apps. Tell them to launch one before retrying.

Tool 2: wh_call — invoke a capability

Three forms:

2a) Single-call

wh_call({
  tool: 'canary.vault.read',          // app.capability.method or capability.method
  args: { key: 'openai' },            // capability-specific
  consent: 'auto',                    // 'auto' | 'prompt' | 'deny'
})

tool formats:

  • app.capability.method — explicit (e.g. canary.vault.read).
  • capability.method — uses the default app or the only running app.

2b) Multi-step (atomic; values flow through resultKey)

wh_call({
  steps: [
    { tool: 'canary.ane.embed',    args: {input: 'hello'}, resultKey: 'vec' },
    { tool: 'canary.vector.store', args: {id: 'h1', vector: '$vec'} },
  ],
  sync: true,
})

2c) Recipe form (looks up a recipe by name)

wh_call({ recipe: 'embed_and_log', params: { intent: 'hello' } })

Response shapes — handle all four:

// Success
{ ok: true, value: ... }

// Consent required (only when consent: 'prompt')
{ ok: false, jobId: 'job_xxx', status: 'requires_consent',
  requires: { bundleId, capability, tier, reason } }

// Typed error
{ ok: false, error: { code: 'consent_denied', reason: '...' } }
{ ok: false, error: { code: 'app_unreachable', bundleId } }
{ ok: false, error: { code: 'unauthorized', bundleId } }
{ ok: false, error: { code: 'timeout', elapsedMs } }
{ ok: false, error: { code: 'tool_not_found', suggestions: [...] } }
{ ok: false, error: { code: 'unknown_app', available: [...] } }
{ ok: false, error: { code: 'ambiguous_tool', candidates: [...] } }
{ ok: false, error: { code: 'capability_not_declared', needed: '...' } }
{ ok: false, error: { code: 'invalid_response', bundleId, reason } }
{ ok: false, error: { code: 'server_error', bundleId, status, body } }

Consent modes — pick the right one

Mode Behavior on needsUser When to use
'auto' (default) Block until the user clicks the consent sheet in the app. Default — works for any MCP client.
'prompt' Return requires_consent envelope immediately with a jobId. LLM clients with strict timeouts. Poll wh_cortex({op:'job.wait', args:{id}}).
'deny' Return consent_denied without prompting. Dry-runs or when the user has said no.

Handling requires_consent (consent: 'prompt' flow)

const r1 = await wh_call({ tool: 'canary.network.connect', args, consent: 'prompt' })
if (!r1.ok && r1.status === 'requires_consent') {
  const r2 = await wh_cortex({ op: 'job.wait', args: { id: r1.jobId, timeoutMs: 30000 } })
  // r2.data.status: 'completed' | 'failed' | 'consent_pending' | 'pending'
  // r2.data.result on success, r2.data.error on failure
}

Tool 3: wh_do — natural-language intent

Use when you have a user goal in prose, not a known tool name.

Schema:

{
  intent: string,            // ≤ 4096 bytes
  app?: string,              // restrict to one app
  dry?: boolean,             // compile only, don't execute
  maxLatencyMs?: number,     // soft cap on LLM provider latency
}

Provider chain (first hit wins):

  1. Recipe — exact-match triggers keywords.
  2. Heuristic — built-in 18-row keyword table (embed, vault, screenshot, etc.).
  3. Cortex — interface seam, no implementation in v1.
  4. LLM — Anthropic-compatible compiler. BYO key from the target app's vault.get('mcp.do.userKey'). Skipped if no vault key.

Response:

{ ok: true,
  plan: [{tool, args}, ...],
  value: any,                     // unless dry: true
  provider: 'recipe'|'heuristic'|'cortex'|'compiler',
  confidence: 0..1,
  cached: boolean }

// or:
{ ok: false, error: { code: 'intent_too_long'|'no_match'|'llm_unavailable'|'app_unreachable'|'call_failed', ... } }

The call_failed variant carries the underlying wh_call error in error.callError — surface that to the user, not a generic "failed".

Tool 4: wh_cortex — ops, jobs, recipes

Single tool, dispatched on op. Nine ops:

op Purpose
health {ok, uptime, jobs: {pending, completed, failed}}
restart Restart a target app (args.bundleId)
graph Snapshot of all running apps and their capabilities
job.wait Block until job reaches completed / failed
job.cancel Cancel a pending job
perception.warmup Warm the perception provider (always returns ready)
recipe.list List installed recipes
recipe.add Install a recipe from JSON
recipe.delete Remove a recipe

Examples:

wh_cortex({op: 'health'})
// → {op: 'health', data: {ok: true, uptime: 142, jobs: {pending: 1, completed: 7, failed: 0}}}

wh_cortex({op: 'job.wait', args: {id: 'job_abc', timeoutMs: 10000}})
// → {op: 'job.wait', data: {id, status: 'completed', result: ...}}

wh_cortex({op: 'recipe.add', args: {recipe: {name, triggers: [...], steps: [...]}}})

Recipes — agent's best friend

A recipe is a deterministic plan stored at ~/.webharness/recipes/<name>.json:

{
  "name": "embed_and_log",
  "description": "Embed input and log a confirmation",
  "triggers": ["embed", "log"],
  "steps": [
    { "tool": "canary.ane.embed", "args": {"input": "$intent"}, "resultKey": "embedding" }
  ]
}

When wh_do's intent contains any triggers keyword, the recipe wins over heuristic + LLM. Zero LLM cost, deterministic.

Best practice for agents: when you've successfully compiled a multi-step plan, offer to save it as a recipe via wh_cortex({op: 'recipe.add', ...}). The user gets a future shortcut; you get a faster + free re-run.

Errors to handle

Error code What it means Recovery
unknown_app No app matches the prefix Re-run wh_discover; ask the user to launch the app
ambiguous_tool Short-form tool resolved to >1 app Re-call with app.capability.method form
tool_not_found Bad string format Use app.cap.method or cap.method
capability_not_declared App lacks this capability in its manifest Tell user; offer to switch to an app that has it
app_unreachable Port-file present but app crashed/closed Tell user to restart the app
consent_denied User said no Stop; don't retry without permission
timeout Probe or call exceeded 5s Retry once; if it persists, suggest a restart
unauthorized Bearer token rejected Port-file is stale; user restarts the app
invalid_response Runtime returned malformed JSON Internal — surface reason to the user
server_error Capability handler threw Surface body; suggest filing an issue

Glossary

Term Meaning
bundleId Reverse-DNS app id (e.g. io.webharness.canary).
port-file ~/.webharness/apps/<bundleId>.json — written at launch, deleted at shutdown. Carries port + bearer token + capabilities + PID.
tier safe (no gate) · caution (logged, no prompt) · sensitive (one-time consent) · dangerous (always consent unless pre-approved).
capability A namespaced verb (e.g. vault.read). Declared in webharness.yml.
manifest The declared capabilities + window + MCP config in webharness.yml.
recipe A deterministic plan in ~/.webharness/recipes/<name>.json.
job An async unit of work tracked by wh_cortex. Has a jobId and a status state machine: `pending → consent_pending → completed

Five worked patterns

Pattern 1 — Read a vault secret

const r = await wh_call({tool: 'canary.vault.read', args: {key: 'openai'}})
if (r.ok) useKey(r.value)
else if (r.error.code === 'consent_denied') stop('user denied')
else if (r.error.code === 'capability_not_declared') stop('app lacks vault.read')
else retry()

Pattern 2 — Embed → store → query (deterministic recipe)

await wh_cortex({op: 'recipe.add', args: { recipe: {
  name: 'note_index',
  triggers: ['index note'],
  steps: [
    {tool: 'canary.ane.embed',    args: {input: '$intent'}, resultKey: 'v'},
    {tool: 'canary.vector.store', args: {id: '$intent', vector: '$v'}},
  ],
}}})
// later — zero LLM cost
await wh_do({intent: 'index note: meeting recap'})

Pattern 3 — Consent-prompt-and-poll (strict-timeout LLM clients)

const r1 = await wh_call({tool: 'canary.network.connect', args: {url}, consent: 'prompt'})
if (r1.status === 'requires_consent') {
  notifyUser(`waiting for consent: ${r1.requires.capability}`)
  const r2 = await wh_cortex({op: 'job.wait', args: {id: r1.jobId, timeoutMs: 30000}})
  if (r2.data.status === 'completed') return r2.data.result
  if (r2.data.status === 'failed')    return notify(r2.data.error)
  if (r2.data.status === 'consent_pending') return notify('user hasn\'t answered yet')
}

Pattern 4 — Discover-then-route across multiple apps

const d = await wh_discover({scope: 'apps'})
const withVault = d.apps.filter(a => a.capabilities.includes('vault.read'))
if (withVault.length === 0) return notify('no apps with vault.read')
const targetId = await askUser(withVault.map(a => a.bundleId))
await wh_call({tool: `${targetId}.vault.read`, args: {key: 'openai'}})

Pattern 5 — Sandbox-safe dry run

const plan = await wh_do({intent: 'export DB as CSV', dry: true})
// review plan.plan, plan.confidence
const confirmed = await askUser(plan.plan)
if (confirmed) await wh_call({steps: plan.plan, sync: true})

Concurrency, idempotency, retries

  • Each app is single-port; concurrent wh_calls to the same app are serialized by the runtime's request loop.
  • wh_call is not automatically retried on timeout — your agent decides.
  • Recipe execution is atomic per step but not transactional across steps. If step 2 fails, step 1's writes are not rolled back. Design recipes idempotent where possible.
  • wh_cortex job.wait polls every 200 ms; default timeout 30 s, capped by args.timeoutMs.

Where to read more