The freeq agent assistance interface is a structured diagnostic surface that lets a bot ask the server why something happened — instead of guessing from raw IRC numerics, log files, or trial-and-error.
It's a small set of HTTP tools at /agent/tools/*, advertised at /.well-known/agent.json. Each tool returns a conclusion (a typed diagnosis code), a short summary a human can read, and machine-actionable fields a bot can branch on. No raw state, no leaks — answers are filtered by who's asking.
This guide walks through a real session captured against irc.freeq.at on 2026-04-26, using the bot in examples/full-validation-bot/ as the canonical implementation.
A standard IRC bot reacts to wire-level events. When a JOIN fails, you get numeric 477 and a string. When a PRIVMSG bounces, you get nothing — silence, sometimes a NOTICE. To build a bot that handles edge cases without a spec sheet open in another window, you have to either:
Agent assistance is the second option.
A practical bot loop looks like:
| Stage | Tool | Why |
|---|---|---|
| boot | discovery (/.well-known/agent.json) |
confirm tools available |
| boot | validate_client_config |
gate startup if your CAPs are wrong |
| post-auth | inspect_my_session |
sanity-check the server's view of you |
| join failure | diagnose_join_failure |
structured cause + fix |
| pre-send | predict_message_outcome |
skip sends that would be rejected (rate-limit, +m, not in channel) |
| reconnect | replay_missed_messages |
gap report |
| on mention | explain_message_routing |
classify wire lines without parsing them yourself |
Every tool returns the same envelope:
{
"ok": true,
"diagnosis": { "code": "SESSION_REPORTED", "summary": "..." },
"safe_facts": [ "...", "..." ],
"suggested_action": "..."
}
$ curl -s https://irc.freeq.at/.well-known/agent.json
{
"service": "Freeq",
"version": "0.1.0",
"description": "Agent-facing assistance interface for Freeq client validation and diagnostic queries. Returns conclusions, never raw state.",
"assistance_endpoint": "/agent/tools",
"capabilities": [
"validate_client_config",
"diagnose_message_ordering",
"diagnose_sync",
"inspect_my_session",
"diagnose_join_failure",
"diagnose_disconnect",
"replay_missed_messages",
"predict_message_outcome",
"explain_message_routing"
],
"auth": { "required": false, "methods": ["bearer"] }
}
Anyone can call any tool. Whether you get a useful answer depends on who you are. Most tools have a SELF_ONLY filter: if you're not authenticated, or if the question is about a session/account that isn't yours, the server returns a *_SELF_ONLY denial and no facts. That's the security model — the surface is public; the disclosure is gated.
The tools live at /agent/tools/* (HTTPS). Your IRC connection lives at wss://irc.freeq.at/irc. Bridging the two is the API-BEARER NOTICE:
:server NOTICE * :API-BEARER stream-9...
The server emits this once, immediately after SASL 903. The token names your IRC stream session. Send it as Authorization: Bearer stream-9... on /agent/tools/* calls and the server resolves the bearer back to your DID and your live session — so SELF_ONLY tools answer fully.
In @freeq/sdk (TypeScript) the SDK captures this for you:
client.on('connectionStateChanged', async (state) => {
if (state === 'connected') {
// brief delay for the post-SASL NOTICE to land
await new Promise(r => setTimeout(r, 1500));
if (client.apiBearer) {
// use client.apiBearer as the Authorization: Bearer ... value
}
}
});
irc.freeq.at¶Below is the verbatim transcript of a fresh did:key bot connecting to production, joining #dev (succeeds), trying #freeq (fails, gated by policy), and using the assistance surface to understand why and what to do.
The bot's source is examples/full-validation-bot/index.ts. Run it yourself with npm install && npm start from that directory.
GET /.well-known/agent.json →
• validate_client_config
• diagnose_message_ordering
• diagnose_sync
• inspect_my_session
• diagnose_join_failure
• diagnose_disconnect
• replay_missed_messages
• predict_message_outcome
• explain_message_routing
validate_client_config is public — no auth needed. Call it before you connect. If it warns about missing CAPs, fix them or refuse to boot.
POST /agent/tools/validate_client_config
diagnosis: CONFIG_OK
summary: Client configuration looks compatible with current server expectations.
The bot generates an ed25519 keypair, encodes the public key as did:key:z6Mk…, and authenticates via SASL ATPROTO-CHALLENGE with method: "crypto" (no PDS, no OAuth). On success the server emits the API-BEARER NOTICE and the SDK exposes it as client.apiBearer.
nick: demobotzmf
DID: did:key:z6MkrvPv3FVcpffV721SY27f72hDXmvRXAhARsJAxXMWTuqU
bearer: captured (stream-9…)
inspect_my_session — what does the server actually see?¶This is the single most useful tool for a long-running bot. Drift between your local state and the server's authoritative state is the #1 source of "why did my bot do X?" bugs. Ask, don't guess:
POST /agent/tools/inspect_my_session { "account": "did:key:z6Mk..." }
Authorization: Bearer stream-9...
diagnosis: SESSION_REPORTED
• Account `did:key:z6Mkrv...TuqU` has 1 active session(s).
• Current nick: `demobotzmf`.
• Declared actor class: `human`.
• AWAY: not set.
• Client signing key registered: yes.
• Negotiated capabilities: message-tags, server-time, batch, echo-message,
account-notify, extended-join, away-notify, multi-prefix
• Joined channels (1): `#dev`.
Without authentication this same call returns INSPECT_MY_SESSION_SELF_ONLY and an empty safe_facts: []. With the bearer, the bot now knows: it's signed in once, on the right nick, with the CAPs it expects, and the message-signing key it just registered is live.
#freeq — fails — diagnose_join_failure explains it¶The bot tries to JOIN #freeq. The SDK fires joinGateRequired. Without the assistance surface the bot would just see numeric 477 and stop. Instead:
POST /agent/tools/diagnose_join_failure { "account": "...", "channel": "#freeq", "observed_numeric": "477" }
diagnosis: JOIN_DENIED
summary: 2 reason(s) prevent did:key:z6Mk... from joining `#freeq`.
• Channel `#freeq` exists with 2 local member(s).
• Channel `#freeq` may have a join policy. Fetch /api/v1/policy/#freeq
for the full requirement set.
• Observed IRC numeric 477: ERR_NOCHANMODES (freeq usage) — channel
requires policy proof acceptance.
suggested: GET /api/v1/policy/#freeq to see what proofs are required.
The bot now has a concrete next action: pull the policy, decide whether it can satisfy the proof requirements, and either present a credential or give up gracefully — instead of retrying the JOIN forever or silently dropping the channel from its config.
The bot calls inspect_my_session and learns it's in #dev only. To enumerate members, the SDK uses RPL_NAMREPLY:
#dev:
• oauth_scopes
• chadfowler.com
• demobotzmf (← that's the bot)
#freeq, even though we couldn't join?¶For channels where membership is gated, the public REST API (/api/v1/users) reports recently-active accounts without joining. The bot uses this to learn what humans are around without bouncing off the policy:
recently active in #freeq: nandi.latha.org
This is intentional: discovery is public, participation is gated.
predict_message_outcome — gate every send¶Before sending, ask: would this reply succeed? The predictor knows the channel modes (+m, +b, +r), the rate limiter's current state for this session, and whether the bot is even a member.
POST /agent/tools/predict_message_outcome { "account": "...", "target": "#dev" }
target #dev: PREDICTED_ACCEPTED — A PRIVMSG to `#dev` from did:key:... should be accepted.
target #freeq: PREDICTED_ACCEPTED
• Sender has 1 live session(s); best one has 0 send(s) in the last 2s window
(limit 5/2s, so 5 send(s) of headroom).
If the predictor returns PREDICTED_REJECTED, the bot logs the reason and doesn't send — instead of blasting a message destined to bounce.
async function safeSend(client, did, target, text) {
const pred = await callTool('predict_message_outcome', { account: did, target });
if (pred?.diagnosis?.code === 'PREDICTED_REJECTED') {
console.warn(`[pre_send] BLOCKED: ${target} — ${pred.diagnosis.summary}`);
return false;
}
client.sendMessage(target, text);
return true;
}
explain_message_routing — interpret a wire line without parsing it¶Useful when building mention-detection or routing logic. Hand the server a raw IRC line and let it tell you what it is:
POST /agent/tools/explain_message_routing
{ "wire_line": ":alice!u@h PRIVMSG #dev :hey demobot can you help with X?",
"my_nick": "demobotzmf" }
diagnosis: ROUTING_EXPLAINED
• Command: `PRIVMSG`.
• Sender: `alice`.
• Target: `#dev` (channel).
• Buffer to route into: `#dev` (bot logic should display the message there).
False-positive guard: if the bot's mention heuristic says "looks like me" but explain_message_routing says it isn't, trust the server.
Same SDK, same calls, same channel — the only difference is the bearer:
Anonymous (no bearer):
preflight -> discovery (no-diagnosis)
preflight -> validate_client_config (CONFIG_OK)
join_failure -> diagnose_join_failure (DIAGNOSE_JOIN_FAILURE_SELF_ONLY) ← denied
pre_send -> predict_message_outcome (PREDICT_MESSAGE_OUTCOME_SELF_ONLY) ← denied
Authenticated (bearer captured from API-BEARER NOTICE):
preflight -> discovery (no-diagnosis)
preflight -> validate_client_config (CONFIG_OK)
post_auth -> inspect_my_session (SESSION_REPORTED)
pre_send -> predict_message_outcome (PREDICTED_ACCEPTED)
explain -> explain_message_routing (ROUTING_EXPLAINED)
The SELF_ONLY denials aren't bugs — they're the disclosure model. Public surface, gated answers.
Three pieces:
typescript
import { generateDidKey, importDidKey } from '@freeq/sdk';
const id = fs.existsSync('./seed.bin')
? await importDidKey(new Uint8Array(fs.readFileSync('./seed.bin')))
: await generateDidKey();
if (!fs.existsSync('./seed.bin'))
fs.writeFileSync('./seed.bin', await id.exportSeed(), { mode: 0o600 });
typescript
const client = new FreeqClient({
url: 'wss://irc.freeq.at/irc',
nick: 'mybot',
channels: ['#dev'],
sasl: { method: 'crypto', did: id.did, signer: id.signer, token: '', pdsUrl: '' },
});
typescript
client.on('connectionStateChanged', async (state) => {
if (state === 'connected') {
await new Promise(r => setTimeout(r, 1500));
const bearer = client.apiBearer;
const r = await fetch('https://irc.freeq.at/agent/tools/inspect_my_session', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${bearer}` },
body: JSON.stringify({ account: id.did }),
});
console.log(await r.json());
}
});
That's it — no PDS, no OAuth flow, no broker. The bot's identity is the keypair on disk; the server resolves did:key:z… in-process.
The full-validation-bot writes every request/response pair to validation.log as JSONL. In production you want this — drift between what your bot thinks happened and what the server reports is invaluable when triaging "why did my bot stop replying?":
tail -f validation.log | jq -c '{stage, tool, code: .response.diagnosis.code}'
examples/full-validation-bot/index.ts — exercises every tool and acts on the answers.generateDidKey, importDidKey, client.apiBearer in @freeq/sdk.https://irc.freeq.at/.well-known/agent.json.freeq-server/tests/agent_assist_authenticated.rs::did_key_sasl_resolves_locally_without_pds.