Skip to main content
A research agent is described by a definition: a TOML or JSON file that says what the agent is (its name, description, version and system prompt), how it works (model, steps, tools, search and knowledge-base settings), what it may spend (cost caps), which skills it has, and when and which sub-agents it starts. The files are written by hand, versioned with git next to the binary, and loaded once when the server starts. A research names the definition it wants ("agent": "news_scanner"); without a name it runs the built-in default, which is today’s research agent. This makes it easy to have several agents that search different areas, behave in a certain way and have preferences, with no change to the Go code. Code: internal/agent_defs (the format, the loader, the checks, the registry, the bundle), internal/agent/research.go (FromDef builds the agent of a definition), internal/agent_research (the manager uses the definition of every agent it starts, the sub-agents included). Examples: build/agent_defs/.

Where the files are

  • The directory agent_defs/ next to the binary (gligabao and gligabao_agent). agent_defs_dir in gligabao.toml or serve --agent-defs-dir (and gligabao_agent --agent-defs-dir) say another one; a relative path in the config file is relative to the file.
  • One agent per file, *.toml or *.json, directly in the directory. The subdirectories hold what the files refer to: prompt files (any subdirectory, by convention prompts/) and skills (skills/).
  • A directory that does not exist is fine: only default is there.
  • The definitions are read once, at startup. A file that is not valid stops the server, with every problem of every file in one error (file, key, what is wrong): an agent that silently does not load is worse than a server that does not start. Change a file, restart the server.

The file

Unknown keys are errors. Every field but name, version, description and the prompt is optional.

The system prompt

system_prompt (or the text of system_prompt_file, a path relative to the file, inside the directory) is the base of the agent’s prompt: it replaces the built-in research prompt. The runtime adds, in this order, what the tools need to be told: the search preferences, the part for the knowledge base (if it has the tools), the part for sub-agents (its roles, or the free-form policy), for a sub-agent its own preamble (its name and the reason its parent gave), and last the skills. So a prompt file is only about the agent’s job and style.

Skills

skills = ["citation_style"] means the file agent_defs/skills/citation_style.md. A skill name is lowercase letters, digits, - and _ (no path, no .md). A skill file is plain Markdown; it is appended to the agent’s system prompt as ## Skill: <name>, in the order of the file (the required ones first). It is read once at startup. One file can be shared by many definitions. A file that is a link out of the directory is refused.

[search]: web preferences

  • Hard settings are enforced. web_search sends the domain lists and the location to Anthropic’s search tool (which takes allowed_domains or blocked_domains, not both, so the loader rejects a file that sets both). fetch_url checks the host of every URL, and of every redirect, itself: a host matches a listed one or any subdomain of it. A refused call is an error the model reads and goes on from.
  • Soft settings (preferred_domains, recency, language, instructions) are text in the prompts; nothing enforces them (the search tool has no recency or language filter).
  • A call over max_calls is refused with an error that tells the agent to write its report with what it has.
  • The settings belong to the agent: a sub-agent has those of its own definition, nothing is inherited.

[knowledge]: the knowledge base

  • access is the one place that gives the knowledge-base tools; listing query or remember in tools is an error. read_only gives query only (and a prompt that says it cannot add); none gives neither. The tools exist only when the server gives its agents a knowledge base at all.
  • Dataset: a locked dataset of the definition, else the one of the request, else the default of the definition, else main. A sub-agent works in its parent’s dataset (so siblings share what they remember) unless its own definition names one.
  • With the modal runner the container’s knowledge base starts empty, so read_only is of little use there (the server logs a warning).

[limits]: cost caps

A cap is a soft stop, not a failure:
  • Before each model call the agent checks its own cost against max_cost_usd, and the cost of the whole research against the cap of the research. When one is reached, that call is the last: the agent is told why and may not call tools, so it writes its report from what it has. The run ends completed. Its writing_report state says the call was made early. A call that is running when the cap is reached still adds its cost, so the cap can be overshot a little.
  • When the cap of the research is reached, every agent of it stops the same way at its next model call, and no more sub-agents can be started (the model is told).
  • The cut is said in the report. The report of an agent that a cap stopped ends with a line, e.g. *Stopped early: the cost cap of this research ($5.00) was reached, so this report is based on what had been gathered until then and may be incomplete.* (or, for the agent’s own cap, this agent's cost cap). The root’s report says it also when the cap stopped only some of its sub-agents. A parent reads the sub-agents’ notes in what wait_subagents gives it.
  • The cap of the research is the lowest of: max_tree_cost_usd of the root’s definition, max_cost_usd of the request (POST /v1/gb/research, MCP, research start --max-cost, gligabao_agent --max-cost-usd), and research_max_cost_usd in gligabao.toml (the server’s ceiling). A request or a definition can only lower what is above it. The built-in default agent has no caps of its own.
  • The cost is what the run records (cost_usd): it comes from the price table, so a model with no price counts $0 and its caps cannot work. The remember and query calls are not in it (deep-research-agent.md).
  • In Modal the agent in the container enforces the caps itself (the definition and --max-cost-usd travel with the job). As a backstop the server ends a job that reports a cost 25% over the cap of the research; the run is then stopped (“stopped by the cost cap”).

[spawn]: sub-agents

  • Without roles, enabled = true is the free-form behaviour of before definitions existed: the model spawns sub-agents that are copies of itself (same definition), with spawn.md as the policy. The built-in default works this way.
  • With roles, the model sees only them. spawn_subagent gets a required role argument (an enum of the on_demand roles), and the prompt lists each role with its does and when: that is the policy of when to start it. A sub-agent runs the definition of its role: its prompt, tools, skills, search and knowledge settings, caps, and its own roles.
  • on_start: the runtime starts the sub-agent before the parent’s first model call, with the rendered task_template as its task, and tells the parent in its first turn that it was started, so it can work on its own part and call wait_subagents. The model does not decide. If it cannot be started, the parent is told.
  • Limits: the code ceilings (depth 2, 5 per agent, 10 per tree, 40 steps) cannot be raised by a file. The depth counts from the root (the root is 0); an agent at its deepest level gets no spawn tool. max_per_tree is read from the root’s definition. The steps of a sub-agent are the role’s default; the parent may ask for more up to max_steps_limit.
  • Checks at startup: a role must name an existing definition whose version meets version, the role’s steps must fit, and no definition may start itself through its roles (a loop is an error).
  • Every sub-agent is a run of its own, with its agent_name, agent_version and agent_hash.

[schedule]: running on a schedule

An agent that should run by itself (a morning briefing, a weekly scan) gets a [schedule]. The server then starts a research with this agent every time the CRON expression matches, as if someone had called POST /v1/gb/research.
  • cron has five fields, as usual: *, numbers, ranges (1-5), lists (1,3,5), steps (*/15, 10-40/10), month names (jan..dec), day names (sun..sat; 0 and 7 are Sunday) and the macros @hourly, @daily (@midnight), @weekly, @monthly, @yearly (@annually). When both the day of month and the day of week are restricted, a day that matches either counts (like cron). There is no seconds field.
  • topic is a Go template with {{.Date}} (2030-01-07) and {{.Time}} (RFC 3339), both of the moment the run was due, in timezone. A run is made as the user, like a request with that gb-user-id: it counts for the user’s limit of working agents, writes to the knowledge base of that user ([knowledge] decides how) and is listed for that user. The run uses the model, steps, caps and sub-agents of the definition.
  • At most one run per agent at a time. If the previous scheduled run of the agent still works when the next one is due, the new one is skipped (a warning is logged). The times the server was down are not caught up: after a start the next time after it is used.
  • At least 5 minutes between two runs (every run is paid for): a schedule that runs more often is a startup error, and so are an expression that never matches (0 0 31 2 *), an unknown time zone and a missing cron or topic.
  • One server should run the schedules. Every process that serves the same definitions starts its own runs, so with several servers behind one database keep the [schedule] sections in one of them only (or paused).
  • Only the server runs schedules: gligabao_agent ignores the section, and an agent that is only used as a sub-agent role is never started by it. The schedule is not part of agent_hash (when an agent runs is not what it is), so changing it does not make a new hash. It travels in a bundle but the container does nothing with it.
  • GET /v1/gb/research/agents shows it: schedule with cron, timezone, topic, user, paused and next_run.
  • The runs are ordinary researches (agent_name says which agent, GET /v1/gb/research?... lists them for the user). There is no separate record of the schedule: what ran is in gb_agent_research_runs.
How the scheduler works inside the server (the loop, the skipping, what is not remembered): The agent scheduler. Code: internal/agent_defs/cron.go (the parser and Next), agent_defs.Schedule, internal/agent_research/scheduler.go (Manager.RunSchedules, started by the server after it is set up).

Model

The model of the main agent is the request’s model, else model of its definition, else the server’s default. A sub-agent runs on the model of its own definition, else on its parent’s: the parent cannot choose it (spawn_subagent has no such argument).

Versions

One file per name; version is metadata, and git keeps the history. Every run records agent_name, agent_version and agent_hash (the SHA-256 of the resolved content: prompt, skills and settings), so a run says exactly what it ran even when a file changed without a version bump. A request can require a version with agent_version (1.2.0, >=1.0, <2, ^1.2, several separated by commas; 1.2 is any 1.2.x): a mismatch is a 400. Two versions of one name cannot be loaded side by side.

Using a definition

  • POST /v1/gb/research {"topic": "...", "agent": "news_scanner", "agent_version": "^1.0", "max_cost_usd": 2}; the MCP tool research_start has the same fields; gligabao research start "..." --agent news_scanner --agent-version "^1.0" --max-cost 2.
  • GET /v1/gb/research/agents (gligabao research agents) lists the definitions: name, version, description, hash, source file, model, steps, caps, knowledge access, skills and roles.
  • gligabao_agent --agent news_scanner [--agent-defs-dir DIR] runs one without the server. By default it reads the same agent_defs directory next to its binary.

In Modal

The container has no agent_defs directory, and baking the files into the image would need a new deployment for every prompt change. So the server sends the definitions with the job: a bundle (agent_defs.Bundle), JSON with the chosen definition, those its roles start (and theirs), the prompts and the skills as text, at most 1 MiB. It travels Remote.Start → POST /research/job_start (agent, agent_bundle, max_cost_usd) → the sidecar writes it to the job’s directory → gligabao_agent --agent NAME --agent-bundle FILE --max-cost-usd N. The binary checks the hash of every definition against its content, so a bundle that was changed on the way is refused.

Checks

Not included yet

Reloading the definitions without a restart, several versions of one name side by side, other triggers than on_demand and on_start (after N steps, when a tool returns something), catching up the runs of a schedule that were missed while the server was down, a schedule that survives several servers (a lock in the database), a schedule that names its own runner, model or dataset, an admin UI view of the schedules, skills the agent loads on demand (today they are all in the prompt), a cap on the cost of a research in the admin UI (the API, MCP and CLI have it), a choice of the agent in the admin UI’s start form, and a research_agents MCP tool. See the roadmap.