Docs Agent patterns
Patterns for agent authors#
How to write an agent, a prompt or a script that drives Tomacco well. The agent guide
is the contract; this page is about using it: plan before acting, wait for jobs properly, act on
errors, ask before destructive calls and recover from failures. The JSON shown is real output of
tomaccoctl 1.1.0, shortened where marked with ... and with local paths replaced.
On this page (8)
Over MCP the operation name is the tool name and the arguments are the same JSON object. The operation reference lists every operation with its arguments and annotations.
Plan with capabilities#
Start a session with one call that says what this computer can do right now, instead of trying an operation and reading its failure:
tomaccoctl call capabilities '{}'
On a computer with no Android tools configured it answered (iOS part shortened):
{"ok":true,"result":{"android":{"build":{"ready":false,"missing":["java","android_sdk"],"gradle":false},
"emulator":{"ready":false,"missing":["emulator","adb"],"avds":[...],"kvm":true},
"devices":{"ready":false,"missing":["adb"],"connected":[]}},
"ios":{"virtual_iphone":{"ready":false,"missing":["profile"],"host_supported":true,"configured":false}, ...},
"desktop":{"ready":true},"next":"doctor has next_steps for what is missing"}}
- Branch on
ready. When it is false,missingnames what is lacking; do not start a build that needs it. tomaccoctl doctor(thedoctortool) hasnext_steps, in order, for setting up what is missing. Steps markedask_firstdownload a lot or accept a license: ask the user before each.capabilitiesruns the tool checks, so it takes a few seconds. Call it once at the start and again after setup, not before every step.- For a project,
project.inspectsays what kind it is andproject.planprints the commands a build would run, without running them. Neither runs the project's build scripts.
Wait on jobs#
A call that starts long work answers at once with a job. Its id is result.id, and queued or
running is not success. Three ways to follow it:
From a script or a terminal, --wait follows the job to its end, with its log on standard
error, and prints the final job:
tomaccoctl call project.execute '{"path":"/absolute/MyApp","platform":"android","action":"build"}' --wait
The exit status is 0 only when the job succeeded.
Over MCP or step by step, repeat job.wait until the state is succeeded, failed,
cancelled or timed_out. Each call waits at most 60 seconds and answers a running job as a
valid result; that is not an error and not a reason to start the work again.
tomaccoctl call job.wait '{"job_id":"JOB_ID","seconds":60}'
For progress, an MCP client that sends _meta.progressToken with job.wait gets
notifications/progress once a second with the job's state and latest log line. After
logging/setLevel info it also gets each new log line as notifications/message. Without MCP,
read the log in pieces and pass next_offset back as offset:
tomaccoctl call job.logs '{"job_id":"JOB_ID","offset":0}'
which answers state, offset, next_offset, text and eof. job.events gives the job's
timeline: each state and phase change with its time.
{"ok":true,"result":{"id":"4986f069a0f74e73b5fcb55b63de0d46","label":"Broken · build · android · debug",
"state":"failed","phase":null,"events":[{"id":1,"kind":"state","value":"queued", ...},
{"id":2,"kind":"state","value":"running", ...},{"id":3,"kind":"phase","value":"building", ...},
{"id":4,"kind":"state","value":"failed","exit_code":1, ...}],"next_after":4,"recorded":true}}
Some jobs do not end while their work is useful. An emulator started by android.avd_start runs
as a job for as long as it is up, and a Flutter run holds its job until it exits. For those, check
the result you need instead: device.list until the emulator is listed with "state":"device".
Jobs persist in a database and survive the client disconnecting. Keep the id: after a restart,
job.get or job.list (filter by state, operation or project) finds the job again. Do not
start the same build twice to find out what happened to the first; an identical request to a busy
project reuses the running job anyway.
Look before you tap#
Guessing coordinates from a screenshot is the commonest way for a UI agent to go wrong. Ask for numbers instead:
tomaccoctl call screen.marks '{"device":"emulator-5554","path":"/absolute/marked.png"}'
tomaccoctl call screen.tap '{"device":"emulator-5554","mark":7}'
tomaccoctl call screen.wait '{"device":"emulator-5554","until":"stable"}'
screen.marksreturns the picture with numbered boxes and the list that says what each number is; the numbers belong to that look.screen.taprefuses a mark whose element has changed (mark_stale) instead of tapping something else: callscreen.marksagain.- Use
screen.treewhen you need text rather than a picture, andscreen.diffafter an action to read what changed. - A screen without a UI hierarchy (the virtual iPhone, a game, a canvas) is read with OCR (
source:"ocr"). Treat it as evidence with a confidence, not as a fact: check the picture when a tap matters. - After an action,
screen.waitfor the text you expect, or for the screen to settle, rather than a fixed pause. Seeing the screen has every argument.
Read the error and its remedy#
Every failure is one JSON object with a code, a message and usually a remedy, and the exit
status is not 0. Branch on code; show the user message; follow remedy. Real examples:
{"ok":false,"error":{"code":"invalid_argument","message":"Unknown argument: arguments.platfrom","remedy":"Did you mean arguments.platform?"}}
{"ok":false,"error":{"code":"invalid_argument","message":"arguments.seconds is out of range","remedy":"Correct the argument the message names; tomaccoctl schema OPERATION lists the accepted keys, types and limits. The call is safe to repeat."}}
{"ok":false,"error":{"code":"tool_missing","message":"adb is not installed/configured","remedy":"Install Android SDK platform-tools"}}
{"ok":false,"error":{"code":"project_unrecognized","message":"No supported project in /absolute/notes","remedy":"Open a Flutter, Gradle, Swift, xtool, web or Xcode project, or add tomacco.json tasks"}}
invalid_argumentis refused before anything runs, so the call is safe to correct and repeat. Read the schema (tomaccoctl schema OPERATION) instead of guessing other keys.- When the place that failed has no remedy of its own,
remedyis the general one for itscode. The error reference lists every code with what it means and what to do. A code passed on from another tool, such as a debug adapter, may still have an emptyremedy; then themessageis the information. A job that ended badly under
--waitanswersjob_failed,job_cancelledorjob_timed_out, with the final job undererror.details.job. A real one, after a Kotlin syntax error:{"ok":false,"error":{"code":"job_failed","message":"Job 4986f069a0f74e73b5fcb55b63de0d46 (Broken · build · android · debug) ended failed", "remedy":"Read the log above, or tomaccoctl call job.logs '{\"job_id\":\"4986f069a0f74e73b5fcb55b63de0d46\"}'", "details":{"job":{"state":"failed","exit_code":1, ...}}}}The log had the compiler's own line,
e: file:///.../MainActivity.kt:71:1 Syntax error: Expecting a top level declaration.Fix the source, then build again.
Safe and destructive operations#
Every operation carries the four MCP annotations, and tomaccoctl schema shows them under
annotations. Of the 250 operations, 86 are read-only and 26 are destructive.
| Annotation | What it means for an agent |
|---|---|
readOnlyHint: true | Changes nothing. Call it freely: capabilities, doctor, device.list, job.wait, project.inspect. |
destructiveHint: true | May delete or overwrite data. Ask the user first, and say what will be lost. |
| neither | Changes state without being marked as deleting data: builds, installs, emulator starts. Fine within the task the user gave. |
idempotentHint: true | Repeating the call with the same arguments has no further effect, so a retry after a lost answer is safe. |
openWorldHint: true | Reaches devices, tools, files or the network outside Tomacco's own state. |
The destructive ones are catalog.remove, project.configure, job.cancel, storage.prune,
device.install, device.app, android.each, android.avd_profile, android.emulator,
remote.run, workspace.remove, files.write, release.version, release.notes, apple.simulator,
apple.device, play.commit, play.abort, ios.emulator.restore, ios.virtual.cleanup,
ios.virtual.state_delete, testing.baselines, profile.baseline_install, update.apply, update.rollback and workflow.run, which runs a destructive
step only when that step is confirmed. The reference marks each.
Two of them are dry runs unless told otherwise. Show the user what the dry run lists, and only then repeat it:
tomaccoctl call storage.prune '{"older_than_days":14}'
tomaccoctl call storage.prune '{"older_than_days":14,"apply":true}'
ios.virtual.cleanup works the same way with dry_run:false. files.write takes the digest
from files.read as if_match, so it refuses to overwrite a file the user changed in between.
Some calls are not destructive by the annotation but still need the user's word, as the
agent guide says: publishing with play.commit or ios.store_ship with upload, and
downloads that accept a license. Over MCP, a client that supports elicitation shows the person the question
itself before these calls run; a consent_declined error means they said no, so do not repeat the call.
When a guard refuses#
The person may limit what an agent does through Tomacco (guardrails). agent.policy
says what this session may do:
tomaccoctl call agent.policy '{}'
A refusal is an error like any other, and nothing ran:
| Code | What to do |
|---|---|
policy_denied | The policy does not allow the operation. Do not look for another operation that does the same. Tell the person what you wanted to run and why. |
approval_denied | The person said no at the desktop, or did not answer in time. Do not repeat the call unless they ask you to. |
path_outside_roots | Write inside the project or one of the roots the remedy lists, or ask the person to add the folder. |
session_job_limit | Wait for a job you started with job.wait, or cancel one you no longer need, then try again. |
result_too_large | The call ran. Read its result with result.read from the result_id in details, passing next_offset back as offset; do not call the operation again for it. |
Recover from failures#
- Find out before retrying. A failed job's log and
job.eventssay where it stopped. Change something, the source, an argument, a setting, before running it again; the same call usually fails the same way. - Do not trust an old artifact. After a failed build, an
.apkfrom an earlier build may still be there. Artifacts in a job's result carrymodified_in_job; check it before installing. - Use the built-in retry where it exists.
android.sdk_install,ios.wda_prepareandandroid.connecttakeretry(countandbackoff_seconds) for network failures. Other operations do not, because repeating them is not known to be safe. - Busy is not broken.
workspace_busynames the job that holds the project. Wait for it, or cancel it withjob.cancelif the user agrees; do not work around it with a copy of the project. A third build waits asqueuedwith aqueued_reason. - Clean up what you start. Stop an emulator you started (
android.emulatorwithstop) and a display session (runtime.stop) when the task is done, unless the user wants them running. - Report what you could not fix.
tomaccoctl reportwrites a local bug-report bundle with the version, host,doctorand the recent failed jobs, secrets masked. It sends nothing; tell the user where it is and that they should read it before sharing it.
tomaccoctl call job.cancel '{"job_id":"JOB_ID"}'
tomaccoctl report /absolute/tomacco-report.tar.gz
A loop that works#
capabilities; stop and set up what is missing, with the user's consent for downloads.project.inspect, thenproject.planfor what a build will run.- Build or run, and wait for the job to end.
- On
failed, readjob.logs, fix the cause and go back to 3. - Look at the result:
device.capture,android.hierarchy,testing.find. A launched app is not a working app until the screen shows it. - Test:
project.executewithinstrumented-test, thentesting.resultswith the test job's id; ortesting.runwith a scenario. - Clean up, and tell the user what ran and what did not.
Your first Android app is this loop with real calls.