Tomacco

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"}}

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"}'

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"}}

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.

AnnotationWhat it means for an agent
readOnlyHint: trueChanges nothing. Call it freely: capabilities, doctor, device.list, job.wait, project.inspect.
destructiveHint: trueMay delete or overwrite data. Ask the user first, and say what will be lost.
neitherChanges state without being marked as deleting data: builds, installs, emulator starts. Fine within the task the user gave.
idempotentHint: trueRepeating the call with the same arguments has no further effect, so a retry after a lost answer is safe.
openWorldHint: trueReaches 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:

CodeWhat to do
policy_deniedThe 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_deniedThe person said no at the desktop, or did not answer in time. Do not repeat the call unless they ask you to.
path_outside_rootsWrite inside the project or one of the roots the remedy lists, or ask the person to add the folder.
session_job_limitWait for a job you started with job.wait, or cancel one you no longer need, then try again.
result_too_largeThe 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#

tomaccoctl call job.cancel '{"job_id":"JOB_ID"}'
tomaccoctl report /absolute/tomacco-report.tar.gz

A loop that works#

  1. capabilities; stop and set up what is missing, with the user's consent for downloads.
  2. project.inspect, then project.plan for what a build will run.
  3. Build or run, and wait for the job to end.
  4. On failed, read job.logs, fix the cause and go back to 3.
  5. Look at the result: device.capture, android.hierarchy, testing.find. A launched app is not a working app until the screen shows it.
  6. Test: project.execute with instrumented-test, then testing.results with the test job's id; or testing.run with a scenario.
  7. Clean up, and tell the user what ran and what did not.

Your first Android app is this loop with real calls.

View this page as Markdown on GitHub