Welcome to Agents Academy

Module 02 · Foundations · ~18 min

Your dashboard.

By the end of this module, you’ll be watching your own agent on your phone, its reasoning, its tool calls, and a kill switch within thumb’s reach, before the agent that feeds the screen exists. The window you’ll trust an LLM with money through.

To get there, you’ll build the operator surface before the agent that fills it. Two templates, a one-page HTML panel and a Telegram bot, both pulling from the same seed NDJSON file so you see your first “trade” on your phone before any LLM has spoken. Every later module wires its output into one of these two surfaces; the dashboard is what makes the agent yours.

Quick answer

Why build a trading agent dashboard before the agent?

Building the dashboard first gives you an operator surface you trust before any LLM has spoken, because you verified it on seed data you wrote by hand; the agent then becomes a thing you watch, not a thing you debug after the fact. The module ships two surfaces over one source of truth, the agent.log.ndjson file on the Module 01 volume: a one-page HTML panel that tails it over Server-Sent Events with collapsible reasoning blocks, a kill-switch button, and an override field, and a Telegram bot that polls the same file and answers /status, /kill, /resume, and /override from your phone. Both are token-gated from the first deploy, and every later module pours real agent iterations into the same file these surfaces already render.

Templates and seed data are illustrative.

Section 01

Why dashboard, then bot.

The classic order, build the agent, then bolt monitoring on at the end, produces an agent you can’t see and a panel you don’t trust. Reversing it gives you the opposite: a panel you trust because you wrote it on something you can verify (seed data), and an agent you watch from the day it does its first useful thing. The bot becomes a thing you watch, not a thing you debug after the fact.

Two templates because the right surface depends on where you are. At your desk, the one-page HTML panel gives you the full reasoning view: collapsible iterations, prompt + tool calls + tool results, kill-switch button, override field. On your phone in line at the supermarket, the Telegram bot pings you when something interesting happens and gives you a one-tap way to halt the agent. Both read from the same NDJSON file; both work today on seed data; later modules pour real iterations through them.

Three things the dashboard must answer in under five seconds

  • What is the agent doing right now. Latest iteration, chosen action, timestamp.
  • Why did it just do what it did. Prompt, tool calls, tool results, in order.
  • How do I intervene without redeploying. Kill switch on, kill switch off, “stop scanning sports markets for the next hour.”

Section 02

Two surfaces, same architecture.

Every panel in this module reads from one file: $ACADEMY_DATA_DIR/agent.log.ndjson. The HTML panel tails it via Server-Sent Events; the Telegram bot polls it on a 5-second timer. Both are stateless on top, the file is the source of truth.

If you’re building a deterministic bot instead of an LLM agent, API Academy Module 02 is the sibling lesson, same architecture, different surface (positions instead of reasoning).

Architecture

┌────────────────────────────────────────────────────────────────┐
│  $ACADEMY_DATA_DIR/agent.log.ndjson    (one event per line)    │
└──────────┬───────────────────────────────────┬─────────────────┘
           │                                   │
           │  tail -f via SSE                  │  poll every 5s
           ▼                                   ▼
┌──────────────────────┐              ┌─────────────────────┐
│  FastAPI panel       │              │  Telegram bot       │
│  /api/log/stream     │              │  python-telegram-bot│
│  /api/state          │              │  /status, /kill,    │
│  /api/kill (POST)    │              │  /override          │
│  /api/override(POST) │              │                     │
└─────────┬────────────┘              └──────────┬──────────┘
          │ HTML+JS                              │ chat msgs
          ▼                                      ▼
   browser on laptop                     phone, anywhere

Section 03

Drop in seed data.

The whole point of building the dashboard first is that it works now, on data you wrote by hand, before any agent exists. Drop these eight lines into $ACADEMY_DATA_DIR/agent.log.ndjson on the host you deployed in Module 01. The panel and the bot will render them.

Seed NDJSON, one event per line

{"ts":"2026-05-01T14:00:01Z","iter":1,"event":"start","prompt":"Scan crypto markets, place at most one $5 trade.","tokens_in":420,"tokens_out":18,"cost_usd":0.0023}
{"ts":"2026-05-01T14:00:14Z","iter":2,"event":"tool_call","tool":"browse_markets","args":{"category":"crypto"},"tokens_in":612,"tokens_out":34,"cost_usd":0.0041}
{"ts":"2026-05-01T14:00:18Z","iter":3,"event":"tool_result","tool":"browse_markets","ok":true,"items_returned":12}
{"ts":"2026-05-01T14:00:33Z","iter":4,"event":"thought","text":"BTC market priced at 0.62, looks rich vs my reading. Skipping. ETH at 0.41 is interesting."}
{"ts":"2026-05-01T14:01:02Z","iter":5,"event":"tool_call","tool":"place_limit_order","args":{"slug":"eth-up-2026","outcome":"yes","side":"buy","price":0.41,"size":5},"tokens_in":1240,"tokens_out":58,"cost_usd":0.0089}
{"ts":"2026-05-01T14:01:04Z","iter":6,"event":"tool_result","tool":"place_limit_order","ok":true,"order_id":"0xabc…","filled":true}
{"ts":"2026-05-01T14:01:06Z","iter":7,"event":"kill_switch_tripped","reason":"manual_panel"}
{"ts":"2026-05-01T14:09:42Z","iter":8,"event":"manual_override","instruction":"Skip sports markets for the next hour, focus on US politics."}

Eight lines: a normal start, a tool call + result, a thought, an order, a kill-switch trip, and a manual override. The panel renders each as its own collapsible block; the bot pushes new lines to your chat.

Get the seed file onto your Railway volume

# From your project root, with the seed lines in seed.ndjson:
railway run "mkdir -p /app/data && cat > /app/data/agent.log.ndjson" < seed.ndjson

# Verify
railway run "wc -l /app/data/agent.log.ndjson"

Section 04

The HTML panel.

One FastAPI app, two endpoints. /api/log/stream tails the NDJSON file via Server-Sent Events, new lines stream to the browser the moment they’re appended. /api/state returns the kill-switch flag and the override slot. The frontend renders each iteration as a collapsible block.

// panel.ts: Hono SSE log tail + state endpoints
import { Hono } from 'hono';
import { streamSSE } from 'hono/streaming';
import { serve } from '@hono/node-server';
import { readFile, watch, appendFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';

const DATA_DIR = process.env.ACADEMY_DATA_DIR ?? './data';
const LOG = path.join(DATA_DIR, 'agent.log.ndjson');
const KILL = path.join(DATA_DIR, 'kill_switch.flag');
const OVERRIDE = path.join(DATA_DIR, 'manual_override.json');
const TOKEN = process.env.PANEL_TOKEN ?? '';

const app = new Hono();

// Token gate (URL ?token=… or X-Panel-Token header)
app.use('/api/*', async (c, next) => {
  const t = c.req.query('token') || c.req.header('x-panel-token') || '';
  if (!TOKEN || t !== TOKEN) return c.text('forbidden', 403);
  await next();
});

app.get('/api/log/stream', (c) => streamSSE(c, async (stream) => {
  // Send everything that's there, then watch for appends.
  let position = 0;
  const flush = async () => {
    if (!existsSync(LOG)) return;
    const buf = await readFile(LOG);
    if (buf.length > position) {
      const chunk = buf.subarray(position).toString('utf8');
      position = buf.length;
      for (const line of chunk.split('\n').filter(Boolean)) {
        await stream.writeSSE({ data: line });
      }
    }
  };
  await flush();
  const watcher = watch(path.dirname(LOG));
  for await (const _ of watcher) await flush();
}));

app.get('/api/state', async (c) => {
  const killed = existsSync(KILL);
  const override = existsSync(OVERRIDE)
    ? JSON.parse(await readFile(OVERRIDE, 'utf8')) : null;
  return c.json({ killed, override });
});

app.post('/api/kill', async (c) => {
  const { on } = await c.req.json();
  if (on) await appendFile(KILL, 'tripped via panel\n');
  else if (existsSync(KILL)) (await import('node:fs/promises')).unlink(KILL);
  await appendFile(LOG, JSON.stringify({
    ts: new Date().toISOString(), event: 'kill_switch_toggled_via_panel', on,
  }) + '\n');
  return c.json({ ok: true });
});

app.post('/api/override', async (c) => {
  const { instruction } = await c.req.json();
  // Scoped: written as a one-shot file the agent reads + deletes per iteration.
  await (await import('node:fs/promises')).writeFile(
    OVERRIDE, JSON.stringify({ instruction, ts: new Date().toISOString() }));
  await appendFile(LOG, JSON.stringify({
    ts: new Date().toISOString(), event: 'manual_override_set_via_panel', instruction,
  }) + '\n');
  return c.json({ ok: true });
});

serve({ fetch: app.fetch, port: Number(process.env.PORT) || 8080 });

What it should look like in the browser, against the seed data, collapsible blocks, hairline borders, a red strip on the kill-switch line, an aubergine strip on the override line.

01 Start: Scan crypto markets, place at most one $5 trade. 14:00:01 · $0.0023
PromptYou are a careful crypto trader. Scan markets, place at most one $5 trade per loop. Stop after 1 hour. Tokensin 420 · out 18
02 tool_call · browse_markets({"category":"crypto"}) 14:00:14 · $0.0041
args{"category":"crypto"} Tokensin 612 · out 34
07 kill_switch_tripped · reason: manual_panel 14:01:06
reasonmanual_panel auditPanel toggle wrote a kill_switch.flag file. Agent halts on the next iteration.
08 manual_override · one-shot instruction 14:09:42
instructionSkip sports markets for the next hour, focus on US politics. scopeOne iteration. The agent reads the override file at the start of its next loop and deletes it after applying.

Section 05

The Telegram bot.

The phone surface. One file, four commands: /status shows the latest iteration, /kill trips the switch, /resume clears it, /override sets a one-shot instruction. The bot polls the same NDJSON file the panel reads, no second source of truth.

// tg_bot.ts: single-file Telegram bot, polling
import { Telegraf } from 'telegraf';
import { readFile, writeFile, appendFile, unlink } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';

const DATA_DIR = process.env.ACADEMY_DATA_DIR ?? './data';
const LOG = path.join(DATA_DIR, 'agent.log.ndjson');
const KILL = path.join(DATA_DIR, 'kill_switch.flag');
const OVERRIDE = path.join(DATA_DIR, 'manual_override.json');
const ALLOWED = (process.env.TG_ALLOWED_USER_IDS ?? '').split(',').map(s => s.trim());

const bot = new Telegraf(process.env.TG_BOT_TOKEN!);

bot.use((ctx, next) => {
  const id = String(ctx.from?.id ?? '');
  if (!ALLOWED.includes(id)) return ctx.reply('forbidden');
  return next();
});

async function lastIter(): Promise<string> {
  if (!existsSync(LOG)) return 'no log yet';
  const txt = await readFile(LOG, 'utf8');
  const last = txt.trim().split('\n').slice(-1)[0];
  return '```\n' + last + '\n```';
}

bot.command('status', async (ctx) => {
  const killed = existsSync(KILL) ? 'TRIPPED' : 'live';
  await ctx.reply(`switch: ${killed}\n${await lastIter()}`,
    { parse_mode: 'Markdown' });
});

bot.command('kill', async (ctx) => {
  await writeFile(KILL, `tripped via tg by ${ctx.from?.id}\n`);
  await appendFile(LOG, JSON.stringify({
    ts: new Date().toISOString(),
    event: 'kill_switch_toggled_via_tg', on: true, by: ctx.from?.id,
  }) + '\n');
  await ctx.reply('halt requested. agent stops on next iteration.');
});

bot.command('resume', async (ctx) => {
  if (existsSync(KILL)) await unlink(KILL);
  await appendFile(LOG, JSON.stringify({
    ts: new Date().toISOString(),
    event: 'kill_switch_toggled_via_tg', on: false, by: ctx.from?.id,
  }) + '\n');
  await ctx.reply('cleared. agent resumes on next iteration.');
});

bot.command('override', async (ctx) => {
  const instruction = ctx.message.text.replace(/^\/override\s*/, '');
  if (!instruction) return ctx.reply('usage: /override <one-shot instruction>');
  await writeFile(OVERRIDE, JSON.stringify({
    instruction, ts: new Date().toISOString(), via: 'tg', by: ctx.from?.id,
  }));
  await appendFile(LOG, JSON.stringify({
    ts: new Date().toISOString(),
    event: 'manual_override_set_via_tg', instruction, by: ctx.from?.id,
  }) + '\n');
  await ctx.reply('override applied to next iteration only.');
});

bot.launch();
@my_agent_bot
Online
/status
switch: live {"ts":"2026-05-01T14:01:04Z","iter":6,"event":"tool_result","tool":"place_limit_order","ok":true,"order_id":"0xabc…","filled":true}
14:02
/kill
halt requested. agent stops on next iteration.
14:02

Mockup, this is what your phone will show.

Section 06

Lock both surfaces.

Panel: a single token

Every /api/* route checks PANEL_TOKEN. Pass it in the URL while you’re experimenting; switch to a header once it’s real.

# Set a long random token
railway variables --set "PANEL_TOKEN=$(openssl rand -hex 32)"

# Open the panel: the token gets baked into the URL once
https://panel.up.railway.app/?token=<value>

Bookmark the URL with the token in it on your phone’s home screen. Rotate the token any time you suspect the URL leaked.

Bot: an allow-list

Set TG_ALLOWED_USER_IDS to a comma-separated list of numeric Telegram user IDs. Any other user gets “forbidden.” Numeric ID, not username, usernames are spoofable.

# Find your numeric ID via @userinfobot
railway variables --set "TG_BOT_TOKEN=<BotFather token>"
railway variables --set "TG_ALLOWED_USER_IDS=12345678,87654321"

The bot rejects every command from users who aren’t on the allow-list, even /status. Default-deny is the only safe posture.

An unauthenticated panel is a kill switch for whoever finds the URL.

The panel is convenience for you; for an attacker who finds it, it’s a button to halt your agent at the worst moment, or to send manual overrides that pretend to be from you. Token-gate from the very first deploy. The Security guide’s incident playbook (Section 07) covers what to do if the token leaks.

Section 07

Manual override safely.

The override field is the most dangerous control on the dashboard, it lets an operator inject a sentence into the agent’s next iteration. Done right, it’s a precision instrument. Done wrong, it’s a second prompt-injection attack surface aimed at your own agent. Three properties, all three required.

1

Scoped

The override applies to one iteration. The agent reads the override file at the top of the next loop, applies the instruction once, and the file is deleted at the end of that iteration. Anything longer-lived is a system-prompt edit dressed up as an override.

2

Expiring

If the agent is asleep when the override is set, the override has a TTL, usually 5 minutes. Past that, it’s discarded. Stale instructions bite hardest when the operator no longer remembers writing them.

3

Logged

Every override that’s set, applied, expired, or denied gets a line in the same NDJSON file. The audit trail covers operator actions just as it covers the agent’s, otherwise the “why” question becomes unanswerable.

In the agent loop, consume + delete + log

# At the top of every iteration, before calling the LLM:
override_text = None
if OVERRIDE.exists():
    o = json.loads(OVERRIDE.read_text())
    age = (datetime.now(timezone.utc) - parse(o["ts"])).total_seconds()
    if age <= 300:                              # 5-minute TTL
        override_text = o["instruction"]
        log({"event": "manual_override_applied", "instruction": o["instruction"]})
    else:
        log({"event": "manual_override_expired", "instruction": o["instruction"]})
    OVERRIDE.unlink()                            # always delete after consuming

# Then build the LLM messages, treating override_text as a user note for THIS turn.

Section 08

The cost meter.

One small panel widget that totals tokens and dollars across the last hour’s iterations. It is not the budget; Module 14 has the hard cap that stops a runaway loop. The meter is the canary, what you glance at to notice the runaway in the ten minutes before the cap trips.

Iter / hr
8
last 60 min
Tokens / hr
12,840
in 9,420 · out 3,420
Cost / hr
$0.087
budget cap $0.50/hr

Compute it from the same NDJSON

# In the panel backend, sum tokens_in/out and cost_usd
# from events whose timestamp is within the last hour.
def cost_window(seconds: int = 3600):
    cutoff = datetime.now(timezone.utc) - timedelta(seconds=seconds)
    iters, tin, tout, usd = 0, 0, 0, 0.0
    for line in LOG.read_text().splitlines():
        e = json.loads(line)
        if "ts" not in e: continue
        if parse(e["ts"]) < cutoff: continue
        iters += 1
        tin   += e.get("tokens_in", 0)
        tout  += e.get("tokens_out", 0)
        usd   += e.get("cost_usd", 0.0)
    return {"iters": iters, "tokens_in": tin, "tokens_out": tout, "cost_usd": round(usd, 4)}

Module 12 wires the agent loop to write tokens_in/out and cost_usd into every iteration. Right now those fields exist in the seed data; the meter renders against them today.

Common questions

The agent dashboard: what people ask

Each answer also ships invisibly as schema.org FAQ data for search engines and AI assistants. Tap a question to expand.

  1. What should an agent dashboard answer at a glance?
    Three things, in under five seconds: what the agent is doing right now (latest iteration, chosen action, timestamp), why it just did what it did (prompt, tool calls, tool results, in order), and how you intervene without redeploying (kill switch on, kill switch off, or a one-shot instruction like “stop scanning sports markets for the next hour”).
  2. How does the agent kill switch work?
    The panel or the Telegram /kill command writes a kill_switch.flag file in the data directory; the agent halts on its next iteration. Clearing it (/resume or the panel toggle) deletes the file. Every toggle also appends an event into the same NDJSON log, so the audit trail covers operator actions just as it covers the agent’s.
  3. How do you secure an agent control panel and Telegram bot?
    The panel checks a single PANEL_TOKEN on every /api/* route (in the URL while experimenting, a header once it’s real); the bot allow-lists numeric Telegram user IDs via TG_ALLOWED_USER_IDS, never usernames, which are spoofable. An unauthenticated panel is a kill switch for whoever finds the URL, plus a channel for fake overrides that pretend to be from you. Token-gate from the very first deploy.
  4. What makes a manual override safe?
    Three required properties. Scoped: it applies to one iteration; the agent reads the override file at the top of the next loop and deletes it after consuming. Expiring: a TTL, usually 5 minutes, discards instructions set while the agent was asleep. Logged: every override that is set, applied, expired, or denied gets a line in the NDJSON audit log. Anything longer-lived is a system-prompt edit dressed up as an override.
  5. What is the cost meter on the dashboard for?
    It totals iterations, tokens, and dollars across the last hour of the log, summing the tokens_in, tokens_out, and cost_usd fields each event carries. It is the canary, not the budget: Module 14 holds the hard cap that stops a runaway loop, while the meter is what you glance at to notice the runaway in the ten minutes before that cap trips.

Section 09

Module checklist.

Both surfaces alive on seed data, both token-gated, both writing back into the same audit log. Module 03 starts you on the agent itself, from here on, every module ends with “wire this into your dashboard.”

Module 02 complete

Yours to watch.

The shift from “I’m debugging a black box” to “I’m watching my agent on my phone” happens here. When the agent starts trading, you’ll see every reasoning step land in this dashboard and you’ll have a kill switch within reach, not buried three menus deep on a desktop.

Concretely, two surfaces alive on hand-written data. The bot you build over the next fourteen modules pours real iterations into the same NDJSON file these surfaces already render.

01

A dashboard built on seed data is a dashboard you trust before the agent is even alive. Every later module fills it in instead of bolting on.

02

Two surfaces, one source. The NDJSON file is the audit trail; the panel and the bot are read-mostly views over it that also write their own actions back.

03

Manual override is a precision instrument with three required properties, scoped, expiring, logged. Anything missing one of those is just a system-prompt edit waiting to bite you.

Next up: Module 03 is a 10-minute crash course on the Limitless API, just enough surface area to wire it as agent tools. Modules 04-06 build the agent basics. Module 05 (Agent Loop) writes its first real iterations into the NDJSON file your dashboard already reads. That’s the moment.

Complete the checklist above to unlock