Worked Examples
The browser-automation examples below are condensed from real, currently-installed builtin apps (an internal-system approval-workflow agent and resources/builtin-apps/meeting-room-booker/spec.yaml) — not invented. Use their structure, not their literal content, for any new browser-automation digital human. The approval-workflow app's real name and target system are internal and not reproduced here; it's referred to below as internal-approval-agent.
1. Minimal WeCom-reachable assistant (no subscriptions)
Exactly what Halo itself auto-creates after a WeCom bot scan-authorization (src/main/apps/runtime/im-channels/wecom-bot-default-spec.ts:52-68, botIdPrefix is the first 8 chars of the new bot's ID, substituted in so multiple scan-auth sessions get distinguishable names):
{
"spec_version": "1",
"name": "WeCom Assistant a1b2c3d4",
"version": "1.0",
"author": "Halo",
"description": "Auto-created WeCom Intelligent Bot assistant. Edit the system prompt anytime in the app detail page.",
"type": "automation",
"system_prompt": "You are a helpful assistant. Keep replies concise and clear. Reply in the same language as the user's message."
}No subscriptions, no config_schema, no permissions needed just to be reachable — the IM binding happens afterward in Settings (create-digital-human/im-triggers.md). Extend system_prompt for the actual task; add subscriptions only if it should also run on a schedule independent of being messaged.
2. Minimal scheduled report
{
"name": "HN Daily",
"description": "Delivers a Hacker News top-stories digest every morning at 08:00",
"type": "automation",
"system_prompt": "You are an HN digest assistant. On each trigger: 1) Open https://news.ycombinator.com and retrieve today's Top 10 stories. 2) Write a concise 2-3 sentence summary for each. 3) Call notify_channel to email the digest to the configured recipient (config.email).",
"permissions": ["ai-browser"],
"subscriptions": [{ "source": { "type": "schedule", "config": { "cron": "0 8 * * *" } } }],
"config_schema": [{ "key": "email", "label": "Recipient Email", "type": "email", "required": true }]
}No output field — as covered above, output.notify is schema-only and does nothing. The email actually gets sent because system_prompt step 3 explicitly tells the agent to call the notify_channel tool; that instruction is the only thing making delivery happen here.
3. Browser-automation digital human with an approval gate (structure pattern)
Adapted from the real internal-approval-agent (name genericized — see the note at the top of this file). Reuse this shape whenever a digital human performs browser actions against an internal system where mistakes are costly:
permissions:
- ai-browser
requires:
mcps:
- id: ai-browser # inert here — kept for parity with the real spec this
# pattern is drawn from; ai-browser is actually granted
# by `permissions: [ai-browser]` above, not this entry.
# Only list a *user-installed* MCP server id here if this
# app must have it on scheduled/triggered runs — see
# create-digital-human/spec-reference.md's `requires` section.
skills:
- id: team/some-site-list-items # a pre-built skill that fetches data via the site's API
- id: team/some-site-do-action # a pre-built skill that performs the action
config_schema:
- key: auto_approve
label: Enable autonomous action
type: boolean
default: false
description: When off, only checks and reports — takes no action.
- key: max_actions_per_run
label: Max actions per run
type: number
default: 10
description: Safety cap to prevent runaway automation.
memory_schema:
processed_ids:
type: array
description: IDs already handled, to avoid repeats. Cap at ~2000.
last_run_at:
type: date
system_prompt: |
You are a <role>. Follow these steps exactly — do not skip steps or improvise.
## Forbidden
- Do not use browser_click/browser_fill on this system's pages for data operations —
use browser_run to call the pre-built skill scripts only (they call the site's API
reliably; clicking through a complex internal UI is fragile and not repeatable).
- Do not take any action not explicitly authorized by config (auto_approve) or by an
explicit, unambiguous user instruction in this turn.
## Steps
1. Establish a browser session against the target site (browser_navigate to
https://internal-system.example.com + browser_wait_for a known landmark; if it fails,
stop and report "not logged in / unreachable").
2. Call the list skill to fetch pending items:
browser_run({ file: ".claude/skills/some-site-list-items/index.js",
params: { pageSize: 30, pageIndex: 1 } })
- result.success === false → report the error and stop.
- result.items is empty → report "nothing pending" and stop.
- otherwise → continue to step 3.
3. Classify each item; when auto_approve is off, everything is report-only.
4. If auto_approve is on, for each matched item (up to max_actions_per_run):
browser_run({ file: ".claude/skills/some-site-do-action/index.js",
params: { itemId: <item.id>, action: "approve" or "reject", memo: "<reason>" } })
Record result.success and result.error (if any) for the report; keep going on failure,
don't abort the whole run over one failed item.
5. Update memory (processed_ids, last_run_at, stats).
6. Produce a Markdown report: overview counts, what was auto-handled, what failed and why,
what needs human attention.
escalation:
enabled: true
timeout_hours: 24
browser_login:
- url: https://internal-system.example.com
label: Internal SystemNo output block — output.notify.system/output.notify.channels/output.format are all schema-only and the runtime never reads any of them (see create-digital-human/spec-reference.md's output section). If this app should also produce a desktop toast, that's a per-app user override the user sets afterward in Settings, not something this spec can configure.
The skill scripts these browser_run calls actually run
requires.skills installs each skill's files under .claude/skills/<name>/ in the space's working directory (src/main/apps/skill-discovery.ts: <workDir>/.claude/skills/<name>/ SKILL.md) — the trailing segment of the skill id becomes the directory name (the real internal-approval-agent spec declares a namespaced skill id and calls it via an index.js in that skill's directory; this example follows the same index.js convention). Every script here must satisfy the constraints in ai-browser/scripting.md: a single async (params) => {...} arrow function, a JSON-serializable return value, and — since browser_run's own timeout only stops the tool call from waiting, not anything still running on the page (ai-browser/scripting.md §4) — its own internal timeout on any fetch().
.claude/skills/some-site-list-items/index.js:
async (params) => {
const pageSize = params.pageSize ?? 20
const pageIndex = params.pageIndex ?? 1
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 10000)
try {
// credentials: 'include' sends the page's own cookies — this is what makes fetch()
// authenticated without the script ever touching a password. It works because
// browser_run executes inside the already-navigated, already-logged-in page (step 1).
const res = await fetch(`/api/todo/list?pageSize=${pageSize}&pageIndex=${pageIndex}`, {
credentials: 'include',
signal: controller.signal
})
if (!res.ok) return { success: false, error: `HTTP ${res.status}` }
const data = await res.json()
// Return only plain data — never a DOM node, never the raw Response object.
return {
success: true,
items: (data.items || []).map(item => ({
id: item.id,
title: item.title,
applicant: item.applicant,
createdAt: item.createdAt
}))
}
} catch (err) {
return { success: false, error: err.name === 'AbortError' ? 'timeout' : String(err.message || err) }
} finally {
clearTimeout(timeoutId)
}
}.claude/skills/some-site-do-action/index.js:
async (params) => {
const { itemId, action, memo } = params
if (!itemId || !action) return { success: false, error: 'itemId and action are required' }
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 10000)
try {
const res = await fetch(`/api/todo/${itemId}/${action}`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ memo: memo || '' }),
signal: controller.signal
})
if (!res.ok) return { success: false, error: `HTTP ${res.status}` }
return { success: true, itemId, action }
} catch (err) {
return { success: false, error: err.name === 'AbortError' ? 'timeout' : String(err.message || err) }
} finally {
clearTimeout(timeoutId)
}
}Note what these scripts do not do, on purpose: no uid from a browser_snapshot anywhere. browser_run's params is inert JSON, JSON.stringify'd into the call (ai-browser/scripting.md §2) — it can never carry a live element reference the way browser_evaluate's args array can. A skill that needs to act on something the page rendered rather than an ID you already have would have to document.querySelector for it inside the script itself, not pass a snapshot uid through params.
Key lessons this pattern encodes:
- Prefer scripted
browser_runcalls over ad-hocbrowser_click/browser_fillwhen a reliable skill script exists for the target site — more repeatable, less likely to break on minor UI changes. Fall back to interactive AI Browser only when no such skill exists yet. This is a reliability preference from real production apps, not a hard platform rule — do not present it to the user as a limitation of Halo itself. - Gate destructive/consequential actions behind an explicit config toggle (
auto_approve) defaulting to off/report-only, plus amax_actions_per_runsafety cap. Seecreate-digital-human/interview-checklist.md. memory_schemafor dedup — every recurring automation that "processes items" needs a processed-IDs list, or it will repeat work every run.browser_logininstead of any credential field — this app assumes the user is already authenticated in their own Halo browser for the target system.- If this app depended on a real, user-installed MCP server (not a built-in like
ai-browser), that server's id would need to be inrequires.mcpsfor the scheduled run to see it — the field is a real least-privilege allowlist for autonomous runs, only inert for built-in capabilities. Seecreate-digital-human/spec-reference.md.
4. Config-heavy scheduled automation (many user-specific values)
Adapted from meeting-room-booker — shows how granular config_schema should get when a task has several independent user preferences (times, type, priority order, target day):
config_schema:
- key: meeting_time_start
label: Start time
type: string # "string", not "text" — short single-line values
default: "16:00"
- key: meeting_time_end
label: End time
type: string
default: "19:00"
- key: room_type
label: Room type
type: select
options:
- { label: Medium, value: medium }
- { label: Small, value: small }
- key: floor_priority
label: Floor priority (comma-separated, highest first)
type: string
default: "20,19,21"
- key: target_weekday
label: Target weekday
type: select
options:
- { label: Monday, value: "1" }
- { label: Tuesday, value: "2" }Don't collapse several independent preferences into one free-text field the AI has to parse loosely — model each as its own typed config_schema entry, exactly as above.