The agenttools package turns the Encounter engine into a catalog of tools an LLM
agent can call. One catalog serves every agent surface in the project:
| Surface | Where it lives | How it reaches the catalog |
|---|---|---|
| In-app assistant on iOS | mobile/encxmobile (AgentSession) |
PicoClaw tools.RunToolLoop over a tools.ToolRegistry |
encli --llm and encli -web |
cmd/encli |
PicoClaw tools.RunToolLoop; the extended CLI tool catalog is registered through a compatibility adapter |
| Standalone PicoClaw, Claude Code, any MCP client | agentmcp + encli mcp |
MCP over stdio |
The CLI catalog predates agenttools and is larger: it includes game-editor
operations, local-file access and Wikipedia helpers. Those tool implementations
still print JSON to stdout and route failures through fatal(), so a compatibility
adapter captures their result, serializes execution, and exposes them as PicoClaw
tools. PicoClaw now owns provider calls, conversation/tool-call iteration and the
iteration limit for both CLI surfaces. agenttools remains the reusable,
CLI-independent engine catalog used by mobile and MCP.
The CLI and web agent accept requests such as “скопируй сюда игру 82864 с
домена svk.en.cx” while the chat is on tech.en.cx. The agent calls
inspect_game_scenario with source_domain and source_game_id, creates a
new destination if needed, then calls admin_copy_game with those source
arguments and target_game_id. The destination always belongs to the current
chat domain; inspecting or copying does not switch the chat to the source.
The source uses its own saved session. If needed, log into that domain in the
web interface or with encli login -domain svk.en.cx before retrying. Target
credentials are not forwarded to the source. Copying uses the scenario importer,
which aligns levels by position and checks a fresh export before returning
verified=true; it does not delete extra destination levels. A mismatch or
interrupted import must not be reported as a completed copy.
Read tools — always available:
| Tool | Reads |
|---|---|
enc_profile |
login, name, rank, team, points |
enc_team |
team name, pending invitations, management actions |
enc_domain_games |
games advertised on the domain front page |
enc_game_list |
upcoming and active games with schedule and type |
enc_game_timeout |
seconds until a game starts |
enc_game_state |
game, level list and the active level in full |
enc_level |
one level: tasks, sectors, bonuses, hints, messages |
enc_action_log |
codes submitted on a level, with correctness and penalties |
enc_view_image |
fetches a picture from level content and returns it to the model |
enc_image_info |
pixel size and format of a picture, plus the parts it splits into |
enc_crop_image |
returns one rectangle of a picture at its own resolution |
enc_split_image |
cuts a collage apart and returns every part as its own image |
enc_game_statistics |
level breakdown, team rankings, per-level timings |
enc_level and enc_game_state list every image referenced by the level's HTML
(task, bonuses, hints, organizer messages) with a note about where each came
from. enc_view_image then downloads one through the authenticated session —
most Encounter attachments are not public — and attaches it to the tool result as
an inline data:image/… entry, which PicoClaw's providers turn into an
image_url part. Without it a model only ever sees a link it cannot open.
Any public host is allowed, because authors host task images wherever they like.
Fetches are bounded at 8 MB per file, and addresses on the local network
(loopback, RFC 1918, link-local) are refused: a URL taken from game content is
untrusted input and the device may sit inside a private network. Set
ResourceOptions.RestrictToDomain to limit fetches to the game's own domain.
Image results are never memoized — a picture is megabytes of base64.
A task picture is regularly a collage: several unrelated pictures pasted into one file, each one a separate clue, and one of them often a screenshot whose text is the answer. Handing that file to a model whole loses the answer, because a provider shrinks a picture to roughly 1568 pixels on its longer side before the model sees it — a 1200×500 collage arrives with its screenshot text below the resolution it can be read at.
enc_image_info → 1200×500 jpeg, 3 parts detected along the horizontal axis
enc_split_image → part 1 (401×500), part 2 (436×500), part 3 (363×500),
each attached as its own image
enc_crop_image → one rectangle, for a detail inside a part
The parts are found in the picture itself rather than guessed: a line of pixels that holds one colour along its whole length is a separator, not content, and the cut goes through the middle of it. Runs of plain background at the ends of the picture are margins and are never cut on. The detector tolerates a spread of 16 per channel, because a collage is saved as JPEG and JPEG rings around the hard edge between a panel and its separator.
parts is a request rather than a promise: when the picture's own separators
produce exactly that many panels they are used, and otherwise the side is
divided evenly and the result says "method": "equal" so the model knows a cut
may run through content. Without parts, a picture that shows no separators is
reported as such instead of being cut at invented positions.
Limits, all of them there to keep one tool call affordable:
| Limit | Value | Why |
|---|---|---|
max_dimension |
1568 by default, 4096 at most | the resolution a model reads at; larger fragments are shrunk by block averaging, not by dropping pixels |
parts |
2 to 8 | more images than a model can hold in one turn, and a file that looks like it has more parts is textured rather than assembled |
| minimum part | 2% of the side, at least 8 px | detection noise should not become a "part"; a sliver is folded into its neighbour so the parts still cover the picture |
| caching | enc_image_info is memoized, the two picture tools are not |
the measurement is a few numbers, a fragment is base64 in the megabytes |
| decoded size | 25 megapixels | the 8 MB fetch cap bounds bytes, not pixels: a few kilobytes of PNG header can declare a picture that would need gigabytes to decode, and a URL from game content is untrusted input. The size is read from the header and refused before anything is allocated |
Decoding accepts JPEG, PNG, GIF, WebP, BMP and TIFF, and the decoder rather than
the server's Content-Type has the last word: a JPEG served as
application/octet-stream is common enough that trusting the header would
refuse pictures that decode perfectly well. A fragment of a JPEG is re-encoded
as JPEG; line art and screenshots stay PNG unless the result is heavy enough to
be a photograph.
agenttools.NewPaced(engine, interval) wraps an Engine so concurrent tools
cannot burst requests at Encounter. An LLM runtime executes the tool calls of one
turn in parallel, and Encounter answers a burst with its anti-spam page instead
of JSON — which reads to a player as a broken session on an account that is
perfectly fine. The default floor is 350 ms, matching what the iOS app already
applies to its own traffic. The embedded agent always goes through it.
Relatedly, encx no longer reports every HTML reply as an expired session: the
anti-spam page is raised as an anti-spam error, an actual login page as an
expired session, and anything else as a neutral "engine returned HTML, retry
shortly".
Mutating tools — gated by the access policy:
| Tool | Changes |
|---|---|
enc_send_code |
submits a level or sector answer |
enc_send_bonus_code |
submits a bonus answer |
enc_take_penalty_hint |
takes a penalty hint (adds penalty time, irreversible) |
enc_enter_game |
submits an application to join a game |
Tool results are compact JSON projections rather than raw engine payloads: a
GameModel carries HTML task text and dozens of presentation flags that would
otherwise consume the model's context window on every call.
| Policy | Behaviour |
|---|---|
readonly |
mutating tools are not published at all, and a direct call is refused |
approve |
mutating tools are published, but each call must be confirmed before it reaches the engine |
full |
mutating calls run immediately |
approve is the library default (agenttools.DefaultPolicy) and needs a
Confirmer. The iOS app implements it as a confirmation sheet; the MCP server
implements it with MCP elicitation.
Options.ReadCacheTTL memoizes read-tool results. Within a single answer a model
commonly re-reads the same facts several times, and each read is a paced round
trip to a slow engine.
Caching is off by default, and it is deliberately conservative when on:
- only read tools are cached, keyed by tool name and arguments;
- any mutating call clears the whole cache, because submitting a code can change the level, the sectors and the action log at once;
Catalog.InvalidateCache()lets a caller drop everything between requests.
The embedded agent uses a 2-minute TTL and invalidates at the start of every turn, so a cached level can never answer a new question — the game moves while the player reads.
The encli mcp subcommand deliberately defaults to readonly instead: an MCP
server is usually started unattended, and an unattended process should not be
able to burn penalty time.
encli mcp -domain tech.en.cx # read-only (default)
encli mcp -domain tech.en.cx -security approve # each mutation confirmed by the client
encli mcp -domain tech.en.cx -security full # unattended mutationsThe server speaks MCP on stdin/stdout, so stdout carries the protocol — all
diagnostics go to stderr. Authentication uses the same saved session as every
other encli command: run encli login first, or pass -login/-password
(or ENCX_LOGIN / ENCX_PASSWORD).
Under -security approve the server asks the client to confirm each mutating
call through MCP elicitation. Clients that do not implement elicitation get a
refusal, never a silent mutation.
Pictures reach an MCP client as image content blocks next to the tool's JSON:
the inline data:image/… entries a picture tool produces are decoded into
ImageContent, because a client that only received the JSON would be told about
a picture it was never handed. Media that is not a decodable inline image is
skipped rather than passed on as a block the client cannot render.
picoclaw mcp add encx-engine -- encli mcp -domain tech.en.cx -security approveor, in PicoClaw's config file:
{
"mcp": {
"servers": {
"encx-engine": {
"enabled": true,
"type": "stdio",
"command": "encli",
"args": ["mcp", "-domain", "tech.en.cx", "-security", "approve"],
"env": {
"ENCX_LOGIN": "player",
"ENCX_PASSWORD": "…"
}
}
}
}
}catalog, err := agenttools.NewCatalog(client, agenttools.Options{
Policy: agenttools.PolicyApprove,
Confirmer: myConfirmer,
})
if err != nil {
return err
}
registry := tools.NewToolRegistry()
catalog.Register(registry) // registry is PicoClaw's *tools.ToolRegistrycatalog.SystemPromptAddendum() returns a short description of the engine and
the active policy, meant to be appended to the agent's system prompt.
mobile/encxmobile exposes the whole thing to Swift through gomobile:
session, err := client.NewAgentSession(`{
"provider": "openai",
"model": "gpt-5.4",
"api_key": "…",
"policy": "approve"
}`)provider selects one of PicoClaw's OpenAI-compatible protocols, and api_base
overrides the endpoint. That endpoint has to serve /chat/completions:
PicoClaw's HTTP provider builds the URL as api_base + "/chat/completions"
(pkg/providers/openai_compat/provider.go), so a gateway that only implements
the Responses API answers 404. A failed turn names the endpoint and the model so
this is diagnosable from the app.
Set auth_method to codex and pass a credential obtained from the device
login:
login, err := encxmobile.StartCodexDeviceLogin()
// show login.UserCode(), send the user to login.VerifyURL()
credentialJSON, err := login.Wait(300)
session, err := client.NewAgentSession(`{
"model": "gpt-5.4-codex",
"auth_method": "codex",
"codex_credential": ` + strconv.Quote(credentialJSON) + `,
"policy": "approve"
}`)The device flow avoids a redirect URI and a local callback listener, which a
phone cannot provide. Tokens are refreshed inside the session; the host reads
CodexCredentialJSON() after a turn to persist a refreshed token. PicoClaw's own
credential store is not used — it reads and writes ~/.picoclaw, which does not
exist on iOS.
This path authenticates against OpenAI with the Codex CLI's public client ID, which is what PicoClaw itself does. Whether a ChatGPT subscription may be used this way is between the account owner and OpenAI's terms.
AgentSession reports progress as JSON events (turn_started, tool_started,
tool_finished, turn_finished, turn_failed) through AgentDelegate.OnEvent,
and asks for authorization through AgentDelegate.OnConfirmationRequest, which
the host answers with ResolveConfirmation(callID, approved).
Setting "location_tools": true in the session config registers
enc_device_location, a parameterless tool that asks the host app for the
device's current GPS position. The Go side cannot reach CoreLocation, so the
request goes through the delegate: AgentDelegate.OnLocationRequest(requestID, turn) fires, the tool call blocks, and the host answers exactly once with
ResolveLocation(requestID, locationJSON) — the JSON is forwarded to the model
verbatim — or FailLocation(requestID, message) when the permission is denied
or no fix is available. An unanswered request fails after a minute; a cancelled
turn fails it immediately. The OS permission prompt still gates the actual fix,
so enabling the tool never reveals a location the player has not granted.
Only the visible transcript survives between turns. Tool output is deliberately not replayed: game state changes while the player reads, so a cached level would be worse than a fresh read.
The agent runtime is PicoClaw (MIT), vendored as a Go module dependency. Its MIT notice applies to the PicoClaw code this project links against.