Skip to content

Tools Reference

LoopForge exposes a small core toolset, plus a compatibility tool surface (aliases + reserved names) so you can reuse prompts/manifests written for common agent tool conventions.

Tool Index (60+)

The table of contents on this page lists section headings, not every tool name. Many tools are grouped under patterns like browser_*, process_*, agent_*, etc.

Use this index when writing prompts/manifests that need exact tool names:

Core

fs_read, fs_write, shell, web_fetch, pdf, pdf_extract

Browser

browser_navigate, browser_back, browser_scroll, browser_click, browser_type, browser_press_key, browser_wait, browser_wait_for, browser_read_page, browser_run_js, browser_screenshot, browser_close

Compatibility aliases

file_read, file_write, file_list, apply_patch, shell_exec, web_search, memory_store, memory_recall

Media

image_analyze, image_generate, location_get, media_describe, media_transcribe, speech_to_text, text_to_speech

A2A

a2a_discover, a2a_send

Sandbox & processes

docker_exec, process_start, process_poll, process_write, process_kill, process_list, canvas_present

Runtime collaboration & scheduling

agent_spawn, agent_list, agent_find, agent_send, agent_kill, hand_list, hand_activate, hand_status, hand_deactivate, task_post, task_claim, task_complete, task_list, event_publish, schedule_create, schedule_list, schedule_delete, cron_create, cron_list, cron_cancel, channel_send, workflow_run, knowledge_add_entity, knowledge_add_relation, knowledge_query

How to read this page

LoopForge tools fall into two execution boundaries:

Boundary Typical use State behavior Implemented in
Standalone tools Files, shell, browser, web, PDF, media, process work Usually act on the current workspace or one request rexos-tools
Runtime-managed tools Agents, hands, tasks, schedules, workflows, outbox, knowledge Persist shared runtime state across sessions rexos-runtime

Use the standalone tools when you want to do work in the workspace. Use the runtime-managed tools when you want to create or query durable state inside LoopForge itself.

If you want the bigger picture, read Runtime Architecture after this page.

Running these examples

Most examples below are written as:

  • a tool call JSON payload (the arguments object), and
  • a prompt you can paste into loopforge agent run.

Quick runner:

loopforge agent run --workspace . --prompt "<PASTE PROMPT HERE>"
loopforge agent run --workspace . --prompt "<PASTE PROMPT HERE>"

fs_read

Read a UTF-8 text file relative to the workspace root.

  • rejects absolute paths
  • rejects .. traversal
  • rejects symlink escapes

Example

Tool call:

{ "path": "README.md" }

Prompt:

Use fs_read to read README.md, then write notes/readme_summary.md with a 5-bullet summary.

fs_write

Write a UTF-8 text file relative to the workspace root (creates parent directories).

Same sandboxing rules as fs_read.

Example

Tool call:

{ "path": "notes/hello.md", "content": "Hello from LoopForge\\n" }

Prompt:

Use fs_write to create notes/hello.md with a short hello message and today's date.

shell

Run a shell command inside the workspace:

  • Unix: runs via bash -c
  • Windows: runs via PowerShell

LoopForge enforces a timeout and runs with a minimal environment.

Example

Tool call:

{ "command": "echo READY && ls" }

Prompt:

Use shell to run a safe command (echo READY and list the workspace). Write notes/shell_output.txt with the full output.

web_fetch

Fetch an HTTP(S) URL and return a small response body.

By default it rejects loopback/private IPs (basic SSRF protection). For local testing you can set allow_private=true.

If truncated=true, LoopForge returns a head+tail snippet with the marker [...] middle omitted [...] and includes both bytes (returned) and total_bytes (original).

Example

Tool call:

{ "url": "https://example.com", "timeout_ms": 20000, "max_bytes": 200000 }

Prompt:

Use web_fetch to fetch https://example.com. Write notes/web_fetch_example.md with: status, content_type, and the first 200 characters of body.

pdf

Extract text from a workspace PDF file (best-effort).

Arguments:

  • path (required): workspace-relative .pdf path
  • pages (optional): page selector (1-indexed), e.g. "1", "1-3", "2,4-6"
  • max_pages (optional): default 10, max 50
  • max_chars (optional): default 12000, max 50000

Returns JSON:

  • path
  • text (possibly truncated)
  • truncated (bool)
  • bytes (file size)
  • pages_total
  • pages (the selector string, or null)
  • pages_extracted

Example

Tool call:

{ "path": "samples/dummy.pdf", "pages": "1-2", "max_pages": 10, "max_chars": 12000 }

Prompt:

Use pdf (or pdf_extract) to extract text from samples/dummy.pdf (pages=1-2). Then write notes/pdf_excerpt.md with: (1) a 6-bullet summary, (2) key terms, (3) any garbled/missing parts you notice. Only use extracted text; do not invent.

See also: PDF Summary case task.

browser_* (CDP)

Browser tools enable headless browser automation via Chrome DevTools Protocol (CDP) (no Python by default):

  • browser_navigate / browser_back / browser_scroll / browser_click / browser_type / browser_press_key / browser_wait / browser_wait_for / browser_read_page / browser_run_js / browser_screenshot / browser_close

Notes:

  • browser_navigate is SSRF-protected by default (denies loopback/private targets unless allow_private=true).
  • Headless by default. To show a GUI window, pass headless=false to browser_navigate (or set LOOPFORGE_BROWSER_HEADLESS=0 as a default).
  • browser_screenshot writes a PNG to a workspace-relative path (no absolute paths, no .., no symlink escapes).
  • Default backend is CDP and requires a local Chromium-based browser (Chrome/Chromium/Edge). If LoopForge can’t find it, set LOOPFORGE_BROWSER_CHROME_PATH.
  • Optional remote CDP: set LOOPFORGE_BROWSER_CDP_HTTP (example: http://127.0.0.1:9222).
  • Optional remote tab mode: set LOOPFORGE_BROWSER_CDP_TAB_MODE=reuse to skip /json/new and reuse an existing page target (default: new).
  • Loopback CDP HTTP (127.0.0.1 / localhost) bypasses proxy settings to avoid corporate proxy misconfig breaking local automation.
  • Optional legacy backend (Playwright bridge): set LOOPFORGE_BROWSER_BACKEND=playwright and install Python + Playwright:
python3 -m pip install playwright
python3 -m playwright install chromium

browser_wait is a selector-only helper (compat). Prefer browser_wait_for when you need to wait for selector or text.

browser_run_js is useful for extracting structured values (like a specific heading) when selectors are tricky. Use it carefully on untrusted pages.

browser_navigate

Starts (or reuses) a browser session and navigates to a URL.

Tool call:

{ "url": "https://example.com", "timeout_ms": 30000, "headless": false }

Prompt:

Use browser_navigate to open https://example.com (headless=false). Then save a screenshot to .loopforge/browser/example.png and close the browser.

See also: Baidu Weather.

browser_back

Go back in history (requires an active session).

Tool call:

{}

Prompt:

Use browser_navigate to open https://example.com, then open https://www.iana.org/domains/reserved, then call browser_back and confirm the URL is back to example.com.

browser_scroll

Scroll the page (requires an active session).

Tool call:

{ "direction": "down", "amount": 800 }

Prompt:

Use browser_navigate to open https://example.com, then call browser_scroll down by 800, take a screenshot to .loopforge/browser/scroll.png, then close the browser.

browser_click

Click an element by CSS selector (best-effort fallback: match link/button text).

Tool call:

{ "selector": "More information" }

Prompt:

Use browser_navigate to open https://example.com, then browser_click \"More information\". Save a screenshot to .loopforge/browser/click.png and close the browser.

browser_type

Type into an input element (requires an active session).

Tool call:

{ "selector": "input[name=\"wd\"]", "text": "北京 今天天气" }

Prompt:

Use browser_navigate to open https://www.baidu.com. Wait for input[name=\"wd\"], then browser_type \"北京 今天天气\" into it, then press Enter. Save a screenshot and close the browser.

See also: Baidu Weather.

browser_press_key

Send a key press (optionally focus a selector first).

Tool call:

{ "selector": "input[name=\"wd\"]", "key": "Enter" }

Prompt:

On a search page, use browser_press_key with key=Enter to submit. If the site blocks automation, fall back to opening a direct results URL.

browser_wait

Wait for a selector (compat helper).

Tool call:

{ "selector": "#content_left", "timeout_ms": 30000 }

Prompt:

Use browser_wait to wait for a results container selector, then read the page text.

browser_wait_for

Wait for a selector or a text substring.

Tool call:

{ "selector": "#content_left", "text": "天气", "timeout_ms": 30000 }

Prompt:

Use browser_wait_for to wait until either #content_left exists or the page contains the text \"天气\". Then read_page and summarize.

browser_read_page

Extract visible text and basic metadata (title/url).

Tool call:

{}

Prompt:

After navigating, use browser_read_page and write notes/page.txt with the first 2000 characters of content.

browser_run_js

Run a JavaScript expression and return its value (use carefully).

Tool call:

{ "expression": "document.title" }

Prompt:

Use browser_run_js to return document.title, then write it to notes/title.txt.

browser_screenshot

Save a PNG screenshot to the workspace (default: .loopforge/browser/screenshot.png).

Tool call:

{ "path": ".loopforge/browser/page.png" }

Prompt:

After navigating, use browser_screenshot to save evidence to .loopforge/browser/page.png.

browser_close

Close the browser session (safe to call multiple times).

Tool call:

{}

Prompt:

At the end of any browser workflow, call browser_close to clean up.

Compatibility aliases

These tool names exist for compatibility and map to LoopForge built-ins:

  • file_readfs_read
  • file_writefs_write
  • file_list → directory listing (workspace-relative; . is allowed)
  • shell_execshell
  • apply_patch → apply *** Begin Patch / *** End Patch patches (add/update/delete)
  • web_search → DuckDuckGo HTML search (best-effort; returns a short text list)
  • memory_store / memory_recall → shared KV store persisted in ~/.loopforge/loopforge.db

file_read

Tool call:

{ "path": "README.md" }

Prompt:

Use file_read to read README.md, then write notes/readme_summary.md with 5 bullets.

file_write

Tool call:

{ "path": "notes/hello.txt", "content": "hello\\n" }

Prompt:

Use file_write to create notes/hello.txt with a short message.

file_list

Tool call:

{ "path": "." }

Prompt:

Use file_list to list files in the workspace root, then write notes/files.md with the listing.

shell_exec

Tool call:

{ "command": "echo hi", "timeout_seconds": 60 }

Prompt:

Use shell_exec to run a safe command and write notes/shell_exec.txt with the output.

apply_patch

Tool call:

1
2
3
{
  "patch": "*** Begin Patch\\n*** Add File: notes/patched.txt\\n+hello from apply_patch\\n*** End Patch\\n"
}

Prompt:

Use apply_patch to add notes/patched.txt with one line of text, then fs_read it back to confirm.

Tool call:

{ "query": "LoopForge harness long-running agents", "max_results": 5 }

Prompt:

Use web_search to find 5 results for \"LoopForge harness long-running agents\". Write notes/search.md with titles + URLs.

memory_store

Tool call:

{ "key": "demo.favorite_color", "value": "blue" }

Prompt:

Use memory_store to save key=demo.favorite_color value=blue. Then use memory_recall to fetch it and write notes/memory.md with the value.

memory_recall

Tool call:

{ "key": "demo.favorite_color" }

Prompt:

Use memory_recall to read demo.favorite_color and print the value.

image_analyze

Analyze an image file in the workspace and return basic metadata as JSON (format, width, height, bytes).

Supported formats: PNG, JPEG, GIF.

Example

Tool call:

{ "path": ".loopforge/browser/page.png" }

Prompt:

Use image_analyze on .loopforge/browser/page.png and write notes/image_meta.json with the returned JSON.

location_get

Return environment metadata as JSON (os, arch, tz, lang).

LoopForge does not perform IP-based geolocation.

Example

Tool call:

{}

Prompt:

Use location_get and write notes/env.json with the returned JSON.

media_describe

Describe a media file in the workspace and return best-effort metadata as JSON (kind, bytes, ext).

Example

Tool call:

{ "path": "notes/readme_summary.md" }

Prompt:

Use media_describe on notes/readme_summary.md and write notes/media_meta.json with the returned JSON.

media_transcribe

Transcribe media into text.

For now this tool only supports text transcript files in the workspace (.txt, .md, .srt, .vtt) and returns JSON (text).

Example

Tool call:

{ "path": "samples/transcript.txt" }

Prompt:

Use fs_write to create samples/transcript.txt with 3 short lines of dialogue. Then use media_transcribe to read it and write notes/transcript.md with the returned text.

image_generate

Generate an image asset from a prompt.

For now this tool outputs SVG to a workspace-relative path (use a .svg filename).

Example

Tool call:

{ "prompt": "A simple SVG badge that says LoopForge", "path": "assets/rexos_badge.svg" }

Prompt:

Use image_generate to create assets/rexos_badge.svg. Then fs_read the file and write notes/badge_preview.md with the first 20 lines.

Runtime collaboration and scheduling tools

These tools are implemented by the agent runtime (not by the standalone Toolset) and persist state in ~/.loopforge/loopforge.db:

  • agent_spawn / agent_list / agent_find / agent_send / agent_kill
  • hand_list / hand_activate / hand_status / hand_deactivate
  • task_post / task_claim / task_complete / task_list
  • event_publish
  • schedule_create / schedule_list / schedule_delete
  • cron_create / cron_list / cron_cancel
  • channel_send (outbox enqueue; use loopforge channel drain to deliver)
  • knowledge_add_entity / knowledge_add_relation / knowledge_query

Practical differences from standalone tools:

  • they usually return JSON records, ids, or workflow state rather than raw workspace output
  • they keep state across later sessions instead of acting only on the current prompt
  • they are useful for collaboration/orchestration flows, not just one-off file edits

agent_spawn

Create an agent session record (persisted) and return its details.

Tool call:

{ "name": "Helper", "system_prompt": "You are a concise assistant." }

Prompt:

Use agent_spawn to create an agent named Helper with a short system prompt. Then call agent_list and write notes/agents.json with the result.

agent_list

Tool call:

{}

Prompt:

Use agent_list and write notes/agents.json with the JSON output.

agent_find

Tool call:

{ "query": "helper" }

Prompt:

Use agent_find with query=helper and write notes/agent_find.json with the result.

agent_send

Tool call:

{ "agent_id": "<agent_id>", "message": "Summarize the workspace README in 3 bullets." }

Prompt:

Use agent_spawn to create an agent, capture its agent_id, then use agent_send to ask it a question. Save the response to notes/agent_reply.md.

agent_kill

Tool call:

{ "agent_id": "<agent_id>" }

Prompt:

Use agent_kill to mark an agent as killed, then confirm via agent_list that its status changed.

task_post

Post a task into the shared task board.

Tool call:

{ "title": "Demo task", "description": "Write notes/task.md with a short checklist." }

Prompt:

Use task_post to create a Demo task, then call task_list and write notes/tasks.json.

task_list

Tool call:

{ "status": "pending" }

Prompt:

Use task_list to list pending tasks and write notes/tasks_pending.json.

task_claim

Tool call:

{ "agent_id": "<agent_id>" }

Prompt:

Use task_post to create a task, then call task_claim to claim the next pending task (optionally pass agent_id). Save the returned claimed task JSON, then call task_complete with its task_id.

task_complete

Tool call:

{ "task_id": "<task_id>", "result": "done" }

Prompt:

Use task_complete to mark a task completed with a short result string, then verify via task_list.

event_publish

Append an event record into the shared event log.

Tool call:

{ "event_type": "demo.finished", "payload": { "ok": true } }

Prompt:

Use event_publish to publish a demo.finished event with payload {ok:true}. Then write notes/event_done.md describing what you published.

schedule_create

Store a schedule record (definition only; execution depends on your runner/daemon setup).

Tool call:

{ "description": "Daily standup reminder", "schedule": "every day 09:30", "enabled": true }

Prompt:

Use schedule_create to create a daily reminder schedule, then call schedule_list and write notes/schedules.json.

schedule_list

Tool call:

{}

Prompt:

Use schedule_list and write notes/schedules.json with the output.

schedule_delete

Tool call:

{ "id": "<schedule_id>" }

Prompt:

Use schedule_create, then schedule_delete the returned id, then confirm it no longer appears in schedule_list.

cron_create

Store a cron job definition (persisted).

If you run loopforge cron worker, LoopForge will execute a small supported subset:

  • Schedules: { "kind": "every", "every_secs": <seconds> }, { "kind": "at", "at_epoch_seconds": <epoch_seconds> }
  • Actions: { "kind": "system_event", ... }, { "kind": "channel_send", ... } (uses delivery as message details)

Tool call:

1
2
3
4
5
6
7
{
  "name": "demo",
  "schedule": { "kind": "every", "every_secs": 300 },
  "action": { "kind": "channel_send" },
  "delivery": { "channel": "console", "recipient": "stdout", "message": "tick" },
  "one_shot": false
}

Prompt:

Use cron_create to store a demo cron definition, then cron_list and write notes/cron.json. (To execute stored jobs, run `loopforge cron worker`.)

cron_list

Tool call:

{}

Prompt:

Use cron_list and write notes/cron.json with the output.

cron_cancel

Tool call:

{ "job_id": "<job_id>" }

Prompt:

Use cron_create, then cron_cancel the returned job_id, then confirm it no longer appears in cron_list.

knowledge_add_entity

Add an entity record to the knowledge store.

Tool call:

{ "name": "LoopForge", "entity_type": "project", "properties": { "repo": "rexleimo/LoopForge" } }

Prompt:

Use knowledge_add_entity to add an entity for LoopForge, then call knowledge_query for \"LoopForge\" and write notes/knowledge.json with the result.

knowledge_add_relation

Add a relation record (edge) between two entities.

Tool call:

1
2
3
4
5
6
{
  "source": "LoopForge",
  "relation": "inspires",
  "target": "meos",
  "properties": { "confidence": 0.8 }
}

Prompt:

Use knowledge_add_relation to relate LoopForge -> meos, then query for LoopForge.

knowledge_query

Search entities/relations (best-effort substring query).

Tool call:

{ "query": "LoopForge" }

Prompt:

Use knowledge_query for LoopForge and write notes/knowledge.json with the JSON output.

channel_send

Enqueue an outbound message into the outbox. Delivery happens out-of-band via the dispatcher:

  • run once: loopforge channel drain
  • long-running: loopforge channel worker

Supported channels:

  • console: prints the message on drain
  • webhook: posts JSON to LOOPFORGE_WEBHOOK_URL

Arguments (tool call JSON):

  • channel (required): console | webhook
  • recipient (required): for console, use something like "stdout"; for webhook, this can be a logical name (the URL is configured out-of-band)
  • subject (optional)
  • message (required)

Example

Tool call:

{ "channel": "console", "recipient": "stdout", "subject": "demo", "message": "Hello from LoopForge" }

Prompt:

Use channel_send to enqueue a console message (recipient=stdout) saying \"Hello from LoopForge\". Then tell me to run `loopforge channel drain` to deliver it.

workflow_run

Run a multi-step workflow and persist execution state to .loopforge/workflows/<workflow_id>.json.

Current scope:

  • workflow_run is best for orchestrating standalone tools such as fs_write, shell, web_fetch, or browser tools
  • it does not currently support nesting runtime-managed tools like task_*, agent_*, knowledge_*, schedule_*, cron_*, or channel_send inside workflow steps

Arguments (tool call JSON):

  • workflow_id (optional): stable id for repeatable runs.
  • name (optional): human-readable workflow name.
  • steps (required): array of step objects.
  • tool (required)
  • arguments (optional object; defaults to {})
  • name (optional)
  • approval_required (optional boolean): force approval gate when approval mode is enabled.
  • continue_on_error (optional): continue after failed steps.

Example

Tool call:

{
  "workflow_id": "wf_demo",
  "name": "write-note",
  "steps": [
    {
      "name": "write",
      "tool": "fs_write",
      "arguments": { "path": "notes/workflow.txt", "content": "hello" }
    }
  ]
}

Prompt:

Use workflow_run to execute one step that writes notes/workflow.txt with \"hello\", then report workflow status.

hand_*

Hands are small, curated “agent templates” that spawn a specialized agent instance.

  • hand_list: list built-in Hands and whether they are active.
  • hand_activate: activates a Hand and returns {instance_id, agent_id, ...}.
  • hand_status: returns the current active instance (if any) for a hand_id.
  • hand_deactivate: deactivates a Hand instance by instance_id (kills its underlying agent).

After hand_activate, you can use agent_send to talk to the returned agent_id.

hand_list

Tool call:

{}

Prompt:

Use hand_list and write notes/hands.json with the output. Pick one available hand id.

hand_activate

Tool call:

{ "hand_id": "researcher", "config": { "topic": "LoopForge" } }

Prompt:

Use hand_activate to activate the researcher hand. Then use agent_send with the returned agent_id to ask it to do a web_search for \"LoopForge\" and summarize 3 bullets.

hand_status

Tool call:

{ "hand_id": "researcher" }

Prompt:

Use hand_status to check if the researcher hand is active, and write notes/hand_status.json with the output.

hand_deactivate

Tool call:

{ "instance_id": "<instance_id>" }

Prompt:

Use hand_activate to start a hand, then hand_deactivate using the returned instance_id. Confirm via hand_list that it is no longer active.

a2a_*

A2A tools let LoopForge talk to external A2A-compatible agents:

  • a2a_discover: fetches the agent card at /.well-known/agent.json
  • a2a_send: sends a JSON-RPC tasks/send request to an A2A endpoint URL

Both are SSRF-protected by default; for local testing you can set allow_private=true.

a2a_discover

Fetch an A2A agent card (LoopForge always requests /.well-known/agent.json on the given host).

Tool call:

{ "url": "https://example.com", "allow_private": false }

Prompt:

Use a2a_discover on a known A2A host and write notes/agent_card.json with the output.

a2a_send

Send a message to an A2A endpoint URL (JSON-RPC tasks/send).

Tool call:

{ "agent_url": "http://127.0.0.1:8787/a2a", "message": "hello", "session_id": "demo", "allow_private": true }

Prompt:

Use a2a_send to talk to an A2A endpoint and save the returned result JSON to notes/a2a_result.json.

speech_to_text

Transcribe media into text.

MVP behavior: supports text transcript files (.txt, .md, .srt, .vtt) and returns JSON with transcript and text.

Example

Tool call:

{ "path": "samples/transcript.txt" }

Prompt:

Use fs_write to create samples/transcript.txt with a short transcript. Then call speech_to_text on it and write notes/stt.json with the returned JSON.

text_to_speech

Convert text into an audio file.

MVP behavior: writes a short .wav file to the workspace (placeholder for real TTS).

Example

Tool call:

{ "text": "Hello from LoopForge", "path": ".loopforge/audio/tts.wav" }

Prompt:

Use text_to_speech to write .loopforge/audio/tts.wav saying \"Hello from LoopForge\". Then use media_describe on that file and write notes/tts_meta.json.

docker_exec

Run a command inside a one-shot Docker container with the workspace mounted.

  • Disabled by default: set LOOPFORGE_DOCKER_EXEC_ENABLED=1
  • Optional image override: LOOPFORGE_DOCKER_EXEC_IMAGE (default alpine:3.20)

Example

Tool call:

{ "command": "echo hello-from-docker && ls -la" }

Prompt:

If you enabled docker_exec, use docker_exec to run a safe command in a container and write notes/docker_exec.json with exit_code/stdout/stderr.

process_*

Start and interact with long-running processes:

  • process_start / process_poll / process_write / process_kill / process_list

Processes run with the workspace as the working directory and a minimal environment.

process_poll returns JSON:

  • stdout / stderr (incremental)
  • stdout_truncated / stderr_truncated (bool; when true, the output contains a head+tail snippet with [...] middle omitted [...])
  • exit_code (null while alive)
  • alive (bool)

process_start

Start a long-running process and return a process_id.

Tool call (macOS/Linux example):

{ "command": "bash", "args": ["-lc", "echo READY; read line; echo ECHO:$line; sleep 30"] }

Prompt:

Use process_start to start a process that prints READY and then echoes one line. Capture the returned process_id for later steps.

process_poll

Tool call:

{ "process_id": "<process_id>" }

Prompt:

Use process_poll in a short loop until stdout contains READY. Then continue.

process_write

Tool call:

{ "process_id": "<process_id>", "data": "hi" }

Prompt:

After READY, use process_write to send \"hi\". Then poll again until you see ECHO:hi.

process_list

Tool call:

{}

Prompt:

Use process_list and write notes/processes.json with the output (verify your process_id is present).

process_kill

Tool call:

{ "process_id": "<process_id>" }

Prompt:

Use process_kill to stop the process. Then call process_list to confirm it is gone.

canvas_present

Save sanitized HTML to the workspace (under output/) and return metadata (saved_to, canvas_id, ...).

Scripts, event handlers (e.g. onclick=), and javascript: URLs are rejected.

Example

Tool call:

{ "title": "Demo report", "html": "<h1>Hello</h1><p>Generated by LoopForge.</p>" }

Prompt:

Use canvas_present to generate a small HTML report with a title and 3 bullet points. Then fs_read the saved_to path and write notes/report_path.txt with that filename.