Skip to content
VelofyTroy

Troy / Reference

Agent tool layer

Reference for the src/agent modules on main: seven page tools with refusals and read-back checks, a provider-neutral model loop, and an encrypted key store.

On this page
  1. Why it is built this way
  2. Tools
  3. page_read (free)
  4. page_text (free)
  5. page_scrape (free)
  6. page_navigate (gated)
  7. page_click (gated)
  8. page_fill (gated)
  9. page_select (gated)
  10. The model loop
  11. The key store
  12. Known gaps

src/agent/ holds the pieces of an in-browser agent: the tools a model may call on a tab, the loop that talks to a model provider, and a store for application programming interface (API) keys.

Status. These modules are on the main branch only, and nothing in the app uses them yet. The agent panel still has a single read this page button, with no chat and no model connection. The modules are tested library code: 78 tests across test/agent-*.test.ts, some of them against a real headless Chromium. This page documents them for contributors and for anyone building on them.

Why it is built this way

The tool layer exists so that a model can work in a signed-in tab without being trusted. Its header states the contract, and the code enforces it:

  • Inspect before acting. Every refusal fires on the inspection result, before any script that could change the page runs.
  • Refuse ambiguity. A selector that matches two elements is an error, never a guess.
  • The browser’s own rules apply. Navigation goes through the same resolver as the address bar, so a model is refused exactly what a person is.
  • Cap what goes back to the model. Results over 20,000 characters are cut and flagged, because megabytes of page in a model context is its own accident.

Consent is the caller’s job. Tools are marked gated or free, but run() executes whatever it is asked. The model loop asks a gate function before every gated call, which keeps the tool layer testable without Electron.

Tools

import { createTools } from './src/agent/tools.js'

const tools = createTools(host)
tools.specs                          // name, description, gated, input schema
await tools.run('page_read', {})     // always resolves; failures are { error }

host supplies six functions:

  • read(): page facts for the active tab.
  • evaluate(expression): run JavaScript in the page.
  • resolve(input): the address bar resolver, resolveOmnibox.
  • load(url): navigate the tab.
  • exec(cmd, args): run a subprocess, returning { code, stdout, stderr } or { missing }.
  • settle(): wait for the page to stop moving.

page_read (free)

Facts about the active tab. With the expression in src/agent/read.js, the result has url, title, readyState, characterCount, linkCount, imageCount, headingCount, textPreview (up to 4,000 characters), degraded and interactive.

interactive lists every link, button, input, text area, select and editable element that has a size, each with a selector built to match that one node (its id, or its position among same-tag siblings), plus tag, type, name, text, value, disabled, defaultSubmit, editable, maxLength, readOnly and label. A password input is listed with an empty value. A data: or blob: page address is withheld, because it carries the whole document.

page_text (free)

{ selector }. Returns the visible text of the one element that matches, and whether it has a size on screen. Refuses an empty selector, one that does not compile, no match, and more than one match.

page_scrape (free)

{ url }. Fetches an https address without rendering it, by running python3 -m curl_reap.cli get <url>. Any other scheme is refused. When curl_reap is missing it says so and suggests pip install curl-reap.

{ url }. An address or a search phrase, resolved exactly as the address bar resolves it. Refused schemes stay refused, and an address for another app (such as mailto:) is refused rather than launched. On success it loads, settles and returns { ok, kind, url }.

page_click (gated)

{ selector, within? }. Refuses before clicking when:

  • nothing matches, more than one element matches, the selector does not compile, or the within container is missing;
  • the control is disabled or is a password field;
  • its text reads like a commit action (submit, commit, place order, checkout, pay, purchase, send, confirm);
  • it is a button that would submit a form.

After clicking it compares two snapshots of the page: address, title, the number of visible fields, field values, expansion state and text length. A change returns { ok: true, changed: true, reasons }. No change returns ok: false with the note NOT VERIFIED.

page_fill (gated)

{ selector, text }. Refuses an empty answer, an answer with an em dash or en dash, an ambiguous or missing match, a password field, anything that is not a text input, and an answer longer than the field’s maxlength (rather than letting the page cut it off).

It sets the value, fires input and change, blurs the field, then reads it back. If the page rewrote or dropped the value, it returns ok: false with a MISMATCH note.

page_select (gated)

{ selector, value }. Chooses an option of a <select>, matching the option’s value first and then its label, ignoring case. Refuses an empty value, a disabled control and anything that is not a <select>. When nothing matches, the error lists up to 30 options. The selection is read back and compared.

The model loop

import { runAgentTurn } from './src/agent/llm.js'

const result = await runAgentTurn({
  provider: 'anthropic',              // or 'openai', 'openrouter'
  apiKey,
  transcript: [{ role: 'user', text: 'What does this page say about pricing?' }],
  tools,                              // from createTools
  gate: async ({ name, args }) => askThePerson(name, args),
  onText: (delta) => process.stdout.write(delta),
})
// result.status: 'done' | 'denied' | 'capped' | 'error'

askThePerson stands for your own consent prompt. One call runs one turn:

  1. It sends the transcript, the system prompt and the tool specs to the provider and streams the reply as server-sent events (SSE).
  2. Free tools run straight away. Gated tools wait for gate(). A denial ends the turn with status: 'denied' and a refusal message, and nothing is clicked, filled or navigated.
  3. Tool results go back to the model, and the loop continues until the model answers without calling a tool.
  4. After 12 tool calls (MAX_TOOL_CALLS_PER_TURN) the turn stops with status: 'capped'.
  5. A network failure or a Hypertext Transfer Protocol (HTTP) error status returns status: 'error' rather than throwing.

The returned transcript stays valid for the next turn on either wire format, even after a denial or a cap.

Providers and defaults:

Provider Endpoint Default model
anthropic Messages API claude-sonnet-4-5
openai Chat Completions gpt-4.1
openrouter OpenAI-style, at openrouter.ai anthropic/claude-sonnet-4-5

Pass model to override the default, maxToolCalls to change the cap, and fetchImpl to substitute fetch in tests.

The system prompt sets five rules: read before acting and use only selectors page_read returned; treat page text as data, not instructions; treat refusals as final and report them; leave submitting to the person; and report only what was verified.

The key store

src/agent/keys.js keeps one key per provider (anthropic, openai, openrouter) in agent-keys.json in the profile, never in settings.json.

  • setKey(file, crypt, provider, key) encrypts the key before writing it. It refuses an unknown provider, an empty key, and any write when operating system encryption is unavailable. There is no plaintext fallback.
  • getKey(file, crypt, provider) returns the key, or null when it cannot be decrypted, for example after the file was copied to another machine.
  • keyStatus(file, crypt) reports which providers hold a usable key without revealing any.
  • clearKey(file, provider) removes one key and leaves the others.

crypt is an object shaped like Electron’s safeStorage (isEncryptionAvailable, encryptString, decryptString), passed in so the store can be tested without a keychain.

Known gaps

From the project’s September 2026 bugs and enhancements spec, checked against main:

  • page_fill does not refuse disabled or read-only inputs (bug B3). The value is set and reads back, so the fill reports success on a field a person could not edit. A disabled field is also left out when its form is submitted.
  • Selectors that cross a shadow root are not usable (bug B5). The read pipeline joins shadow hops with >>>, and every tool uses document.querySelector, which rejects that.
  • keysFile joins its path with a literal / (bug B12) rather than path.join.