Skip to content

Automation Examples

Tony West edited this page Aug 28, 2026 · 4 revisions

Automation Examples

Two worked examples of pointing an agent at Joro: a read-only agent that surveys captured traffic, and a write-capable agent that sets engagement scope. See Automation for the capability catalog, the token model, and the guard these rely on.

The wiring shown is Claude Code, because that is the client Joro generates configuration for. The token design is client-agnostic — any MCP client connects the same way — but running a server privately inside one subagent is a Claude Code feature.

The pattern

One job, one grant list, one token, one agent. Joro's Automation profiles are already built as one-job bundles, so a profile is usually the right starting point for an agent's grant list.

Three things constrain an agent, and they do not constrain equally. In order of what actually binds:

  1. The grant list is the real control. An ungranted capability is not merely refused, it is never named in the agent's tool listing, so it costs the model no context and invites no attempt. Narrowing grants is the only layer that removes a capability outright.
  2. Token policy bounds sends and credential visibility, not reads. The scope requirement and the host whitelist are evaluated against a target, and read capabilities have none — so a host whitelist does not narrow what an agent can read out of already-captured traffic. If you want to limit reading, do it with grants.
  3. Keep the client off the shell. Not because a shell is another route to the MCP port, but because Joro's local UI API admits any local process with no token at all. A shell bypasses the token model entirely, so an agent that should be held to its grants must not have one.

As Automation puts it: a token is a leash on the agent, not a sandbox on the machine.

Example: a read-only recon agent

An agent that reads captured traffic and reports what it finds. This is the shape to copy.

The token. Create it from the Recon (read-only) profile — 14 grants, no sends, no writes:

  • Only allow sends to in-scope targets: leave on. It grants nothing that sends, so this costs nothing and keeps the token narrow if you widen its grants later.
  • Host whitelist: target.com, *.target.com. Two entries, because * does not stop at a dot — see Automation. Note this bounds nothing on a read-only token; it matters only if you later add a send capability.
  • Show credential header values: leave off. The agent still sees that an Authorization header is present, and can hold a session through its own cookie jar without reading one.

.claude/agents/joro-recon.md:

---
name: joro-recon
description: Surveys an engagement's captured traffic in Joro — hosts, endpoints,
  findings and interesting responses. Read-only; sends no traffic and changes nothing.
  Use when you need to know what has been captured before deciding what to test.
tools: Read, Grep, Glob, Write, Edit, mcp__joro
mcpServers:
  - joro:
      type: http
      url: http://127.0.0.1:9091/mcp
      headers:
        Authorization: "Bearer ${JORO_RECON_TOKEN}"
model: sonnet
---

You survey captured traffic in Joro and report what is there. You are read-only: you
cannot send requests, change the proxy, or edit findings, and you should not describe
those as options.

Start with instance_get to see whether the instance you are pointed at is empty,
scoped, or already triaged, and say so in your report rather than assuming.

Credential header values are masked for you. A masked header is present, not absent —
never report an endpoint as unauthenticated on that basis.

You have no shell. If a task needs anything beyond reading Joro and writing local
notes, report that back instead of working around it.

Two syntax traps, both of which produce an agent that silently has no Joro tools at all:

  • mcpServers is a list, and each entry is a single-key map — note the - and the extra indent above. Writing it as a plain map (mcpServers: then joro: at one level of indent) parses, but the server is never connected.
  • tools gates MCP tools too. An allowlist that omits mcp__joro removes every Joro tool along with Bash. The mcp__joro entry admits that one server's tools; drop it and the agent reports it has none.

If an agent tells you it has no Joro tools, those are the two things to check, in that order.

Keep the secret out of the file. ${VAR} is expanded in headers, so export JORO_RECON_TOKEN in your shell rather than pasting the token. A Joro token reads captured traffic, including credentials — treat it like the engagement data it unlocks, and do not commit it. Putting the agent in ~/.claude/agents/ instead of the project keeps it out of the repo entirely.

Why define the server inline rather than in .mcp.json: an inline server is connected when the subagent starts and disconnected when it finishes. It never appears in the main thread's /mcp list, no other subagent can reach it, and its tool descriptions cost the parent conversation no context.

Expect the rate limit to shape the work. A Recon token allows 60 requests a minute with 2 running at once. Ask for a survey — history_stats, then sitemap_get, then targeted http_search — rather than a per-request sweep, which is what Joro's own tool descriptions steer an agent toward anyway.

Example: a scope-setup agent

An agent that sets engagement scope. This one is the exception to the pattern above, and worth reading even if you do not build it.

Scope writes require the leash off. scope_addrule and scope_enable are refused on any token that requires an in-scope target or carries a host whitelist, because scope is the control restricting such a token. So the instinct to narrow the token as far as possible produces the one shape that cannot do this job.

The token. Five hand-picked grants — instance.get, scope.get, scope.addrule, scope.enable, notes.create — with Only allow sends to in-scope targets turned off and the host whitelist empty. Because the token restrictions are off, the grant list is the entire control: with these five the agent can read no captured traffic at all.

Turning the scope requirement off is not optional here, and forgetting it fails quietly. A token defaults to requiring scope, and nothing refuses the combination at creation time — the two write tools simply do not appear in the agent's listing:

requires scope:  instance_get  notes_create  scope_get
scope exempt:    instance_get  notes_create  scope_get  scope_addrule  scope_enable

The agent does not get a permission error — it reports that it has no tool for the job. The readable error only surfaces if the model guesses the name:

error: token_restricted: scope.addrule is unavailable to a token that requires an
in-scope target: it would let the token edit the control that restricts it. Use a
token with requireScope disabled and no host whitelist.

Why not the Engagement setup profile. It fits the job but is wider than it: 16 grants, adding Match & Replace, Custom Data, noise and detection writes that a scope agent has no use for — and noise editing is the one capability in the registry that can lose traffic with no record. It also omits notes.create, which Joro's own guidance tells agents to use. Hand-pick the five.

The verb set is add-and-enable only. Joro states it in the tool description the agent reads: "Rules are include-only: there is no way to add an exclude rule, remove a rule, or disable scope from here, because those reduce what Joro records — ask the operator." An agent cannot narrow scope, remove a rule it added, or turn scope back off. Write the agent's description to match — "extends scope", not "manages scope".

scope_enable is the highest-consequence call it has. It changes what Joro captures for the human at the keyboard, immediately, with no undo, so put it behind an explicit confirmation. It refuses on an empty rule set rather than silently stopping all capture:

error: handler_error: refusing to enable scope with no include rule: 0 rules present,
none of them an include. Enabled scope matches only what an include rule covers, so
this would stop Joro capturing anything. Add an include rule with scope_addrule first

The agent cannot choose the engagement. No capability selects, switches, or names a project — scope writes land in whatever project is active. instance_get reports it on the first line:

joro v1.13.0  proxy=127.0.0.1:8080  project=(unsaved session)
captured=0
scope=off rules=0

So load the right project yourself before invoking the agent. The best the agent can do is read that line, compare it against the engagement it was told to work on, and refuse on a mismatch — a prompt-level control, not an enforced one. (unsaved session) is worth refusing on outright: with no active project the rule is live but never persisted.

Example: an installed automation

The two agents above are constrained by what you granted them. An installed automation is constrained by code you wrote and read — a different trade, and often the better one for work you repeat. Start Joro with --automation-scripting, then open SettingsAutomationScripting and pick New ▾Script.

This one sweeps captured traffic for responses that look like they leaked a stack trace, and highlights the rows so you see them while reading History yourself. It sends nothing.

joro.json:

{
  "id": "trace-sweep",
  "name": "Stack trace sweep",
  "version": "1.0.0",
  "description": "Highlights captured responses that look like an unhandled server error.",
  "sdkVersion": "1",
  "entrypoint": "index.js",
  "triggers": ["manual"]
}

index.js:

const SIGNS = [/at [\w.$]+\(.*\.java:\d+\)/, /Traceback \(most recent call last\)/, /\bat .*\.cs:line \d+/]

async function run(ctx) {
  const host = ctx.input?.host
  const table = await joro.history.list({ status: '5xx', limit: 200, ...(host ? { host } : {}) })

  // Rows are space-padded text, preceded by a note line and a header. A leading
  // integer is the seq, and is what every other capability addresses a request by.
  const seqs = table.split('\n')
    .map((line) => line.match(/^(\d+)\s/))
    .filter(Boolean)
    .map((m) => Number(m[1]))

  const hits = []
  for (const seq of seqs) {
    const body = await joro.http.read({ seq, part: 'resp', section: 'body', length: 8192 })
    if (!SIGNS.some((re) => re.test(body.text))) continue

    await joro.history.highlight({ seq, color: 'red' })
    hits.push(seq)
  }

  console.log(`${hits.length} of ${seqs.length} candidates highlighted`)
  return { hits }
}

Three things about it are worth copying. It filters server-side with status: '5xx' rather than pulling every row and testing in JavaScript, which is the difference between one call and a budget spent on rows it was going to discard. It reads a bounded prefix of each body rather than the whole thing, because a run has a byte budget across every call it makes and a handful of large responses will exhaust it. And it returns the hits instead of only logging them, so the run report is something you can act on and an agent invoking it through script_invoke gets a value rather than prose.

Note the two return shapes in play. http.read hands back an object, so the bytes are body.text and nothing but the bytes — the window's own metadata is in body.totalLength, body.truncated and the rest. history.list and most of the rest of the SDK hand back a text rendering instead, written for a model reading the output, so you match against it as a string. Run a method once from the editor and read what comes back before writing a parser against it.

Run executes the buffer without installing anything, which is how you test a draft — the report underneath shows the console line, the returned value, and how the run ended. Set Test input to {"host": "target.com"} to exercise the filter. When it does what you want, Install writes it to ~/.joro/automations/trace-sweep/.

It installs disabled, which is deliberate: everything is reviewed before it is armed. For a manual automation that only affects whether an agent holding script_invoke may call it — you can always run a disabled one yourself from the editor.

To make it fire on its own, add "request.captured" to triggers and enable it. Read Scripting first on what changes when you do: runs arrive batched rather than one per request, a script that sends can re-trigger itself, and a runaway is broken by pausing the automation rather than by anything you have to notice.

"request.captured" fires on every proxied request. To narrow it, build a custom trigger in Settings → Automation → Scripting — say request.captured where the status is 2xx and the path starts with /api/ — and name that instead:

"triggers": ["api-responses"]

The automation then wakes only for the requests that satisfy it. That matters most for a command automation, which has one standard input and cannot filter its own batch the way a script can. See Scripting#custom-triggers.

Writing the skill

Joro already ships tool guidance, sent to every client on connect. It covers addressing requests by sequence number, preferring ranges and diffs over whole bodies, that a masked header is present rather than absent, preferring a per-request edit over a global rule, using notes_create to explain itself, and that "a tool you cannot see is one you do not have."

So a skill should carry engagement procedure and stop conditions, not tool mechanics. Repeating what Joro says spends context to restate it and risks contradicting the server as capabilities change. That is also why the recon agent above has no skill: its job is open-ended reading with no irreversible step, and Joro's own guidance already describes the cheap path through the read tools. Reach for a skill when a job has an order that matters and steps that cannot be undone — which is exactly the scope agent.

.claude/skills/scope_extend_workflow/SKILL.md:

---
name: scope_extend_workflow
description: Procedure and stop conditions for extending Joro's engagement scope.
  Preloaded by joro-scope-agent.
---

# scope_extend_workflow

1. **Confirm which engagement you are in.** Call `instance_get` and read `project=`.
   If it does not match the engagement named in your task, or reads
   `(unsaved session)`, stop and report — scope writes land in whatever project is
   active, and you cannot change it.

2. **Read the current scope before writing.** Call `scope_get` and list what is
   already there. `scope_addrule` does not de-duplicate: adding a rule that already
   exists creates a second identical one, so skip anything already covered.

3. **Add the smallest set that covers what was asked.** A hostname needs two rules
   to cover itself and its subdomains, because `*` spans dots: `target.com` and
   `*.target.com`. Patterns are hostnames only — no scheme, no path, no port. A path
   restriction goes in the `path` field.

4. **Never retry a write on an ambiguous result.** A failed `scope_addrule` adds
   nothing; a timed-out one may have added a rule. Call `scope_get` to find out
   which, and report rather than guessing.

5. **Ask before enabling scope.** `scope_enable` changes what Joro intercepts for
   the operator's own browsing, takes effect at once, and cannot be undone from
   here. Report what enabling it would do and wait for a decision.

6. **Record what you did.** Call `notes_create` with the host and a one-line
   record of what you added and why. Do not write a local log file — the note is
   attributed to you and travels with the engagement.

7. **Report before and after.** End with the rule list from step 2 and the rule
   list as it stands now, so the operator can see the delta without reading a log.

The frontmatter needs only name and description; argument-hint is available if the skill takes arguments. A subagent that lists a skill under skills: gets the full body injected at startup rather than the description alone, which is what makes a skill the right shape for procedure text.

Add skills: and the matching prompt to the scope agent's frontmatter:

---
name: joro-scope-agent
description: Extends engagement scope in Joro — adds include rules and enables scope
  filtering. Cannot remove rules, exclude hosts, or disable scope. Use when a host
  needs to be brought into scope before testing it.
tools: Read, Grep, Glob, Write, Edit, mcp__joro
mcpServers:
  - joro:
      type: http
      url: http://127.0.0.1:9091/mcp
      headers:
        Authorization: "Bearer ${JORO_SCOPE_TOKEN}"
skills:
  - scope_extend_workflow
model: sonnet
---

You extend engagement scope in Joro, and that is all you do. Follow the
scope_extend_workflow skill on every task.

Scope is the interception decision, not a display filter: a rule you add makes Joro
terminate TLS for that host and record its plaintext, including the operator's own
browsing. Add what was asked for and nothing wider.

If a request is ambiguous — a hostname with no clear owner, or a range you cannot
map to a rule — stop and ask. A partial or best-guess rule cannot be removed here.

Running it

Agents go in .claude/agents/ for one project or ~/.claude/agents/ to have them everywhere; a project agent wins over a personal one of the same name. Skills follow the same split, under .claude/skills/ or ~/.claude/skills/.

Claude Code watches both directories and picks up edits within a few seconds, so you do not need to restart to change an agent. The exception is the one you hit here: creating the first file in an agents directory that did not exist when the session started needs a restart, because only directories present at startup are watched.

Then invoke it one of three ways:

  • Automatic delegation — describe the work in conversation and Claude Code routes to the agent when the task matches its description. Write that field for matching, not for prose.
  • Explicitly@agent-joro-recon survey what we have on target.com. Note the agent- prefix.
  • As the whole sessionclaude --agent joro-recon, when you want everything constrained to that agent's tools and token.

Reviewing what the agent did

Activity, under SettingsAutomation, is the live view. It records one row per attempt, so refusals and probes for ungranted tools land alongside successful calls, and a mutating call carries a readable description of what it changed:

scope-setup   scope.addrule   ok      add include host=target.com methods=* path=*
scope-setup   scope.addrule   ok      add include host=target.com methods=* path=*
scope-setup   scope.enable    error   handler_error
scope-setup   scope.enable    ok      enable scope (rules=2)
scope-setup   notes.create    ok      add note host=target.com (56 bytes)

Those two identical rules are what a retry looks like — the reason step 2 of the skill exists.

For an agent that sends, its traffic is in History like any other request, scanned by Detect and entered in the Map, because automation sends go through Joro's own proxy.

The Scope panel will look stale. No event is broadcast when scope changes, so the panel reflects an agent's write only after a reload. Activity is where it appears live.

Activity is an in-memory ring of 2000 entries that does not survive a restart. A note from notes_create does, which is the other reason to have the agent write one.

Related

  • Automation — the capability catalog, token model, profiles, and the scope guard
  • Settings — the Automation category, and the scope rules an agent's sends are checked against
  • History — where a sending agent's traffic lands
  • Detect — findings an agent can record or re-grade

Clone this wiki locally