Agent files
An agent is a folder with two required files. There’s no registration step and no build: if the folder has a SOUL.md and a tools.py, it’s an agent.
recipes/ ← the folder name is the agent's handle: @recipes├── SOUL.md ← required: who it is, how it speaks, what it does├── tools.py ← required: its tools, as schemas + Python functions├── README.md ← optional: notes for people (and Agent City's rename)└── evals.json ← optional: real questions it should handleThe Agent builder writes all of this for you. This page explains what it writes, for when you want to write or change an agent by hand, or publish one.
Where agent folders live
Section titled “Where agent folders live”| Folder | What’s there |
|---|---|
~/Library/Application Support/Agent City/agents/ |
Agents you make. Put hand-written agents here. |
~/Library/Application Support/Agent City/memory/agents/ |
Agents installed from sources. Don’t edit these: an update replaces them. |
| Inside the app | The starter team and shipped agents. Read-only. |
If two folders have the same name, yours wins. After adding or changing a folder by hand, choose Restart Agent City in the menu bar to load it. Changes made through the Agent builder load without a restart.
SOUL.md
Section titled “SOUL.md”The soul is the agent’s system prompt, written in Markdown, with YAML front matter on top:
---description: Recipes and cooking help, from what's in your fridge to a full dinner plan.version: 1.0.0author: Ann Leerole: Recipes & cookingexamples: What can I make with eggs and spinach? | A vegetarian dinner for four---
# Basil
## Who I amI'm Basil, Agent City's cook. Warm, a little bossy, and never without an opinion on salt.
## How I speak- Brief. Under 60 words unless you ask for the full recipe.- Metric units unless asked otherwise.
## What I do- I find recipes with `find_recipes`, and I never invent one.
## What I don't do- I'm not a general assistant. For anything else, I ask the right member of Agent City.| Front matter | Required | Used for |
|---|---|---|
description |
Yes | Routing. The coordinator’s instructions list every agent with this one line, and it decides who to ask from it. Make it specific about what the agent can do. |
version |
No | Shown on the agent’s card and to people installing it. Bump it when you publish a change. |
author |
No | Shown on the card and in Browse. |
role |
No | The short label under the agent’s name, like Weather & forecasts. |
examples |
No | Up to four example questions, separated by |, shown on its card. |
The # Heading is the agent’s display name. The front matter is removed before the soul reaches the model.
Writing a soul that small models follow
Section titled “Writing a soul that small models follow”- Use concrete limits (“under 40 words”) instead of “be brief”.
- Use placeholders in example replies (
<city>,<temp>), or small models copy the example word for word. - Say which tool answers what, and that every answer comes from a fresh tool call.
- Say what it doesn’t do, so it hands other questions back.
tools.py
Section titled “tools.py”An ordinary Python module. Agent City imports it into its own process, and it can use the standard library and the packages Agent City ships with. Two names are required:
import jsonimport urllib.request
def find_recipes(ingredients, diet=None): """Recipes that use these ingredients.""" if not ingredients.strip(): raise ValueError("Say at least one ingredient.") # the model sees this message ... return {"recipes": [{"name": "Spinach omelette", "minutes": 10, "url": "https://…"}]}
TOOLS = [ { "type": "function", "function": { "name": "find_recipes", "description": "Find recipes that use the given ingredients.", "parameters": { "type": "object", "properties": { "ingredients": {"type": "string", "description": "Comma-separated, e.g. 'eggs, spinach'."}, "diet": {"type": "string", "enum": ["vegetarian", "vegan", "gluten-free"]}, }, "required": ["ingredients"], }, }, },]
HANDLERS = {"find_recipes": find_recipes}| Name | Required | What it is |
|---|---|---|
TOOLS |
Yes | A list of tool schemas in OpenAI function-calling format. |
HANDLERS |
Yes | A dict from each tool’s name to the Python function that runs it. Every name in TOOLS needs one. |
When the model calls a tool, Agent City calls the handler with the arguments as keyword arguments and sends what it returns (usually a dict, as JSON) back to the model. If the handler raises, the model sees the error message, so make it helpful.
Design tools that are hard to misuse
Section titled “Design tools that are hard to misuse”Small models make predictable mistakes. Defend against them in the tool rather than in the prompt: if the weather tool is asked for one day, it still returns today and tomorrow, so “tomorrow” questions work.
Optional names
Section titled “Optional names”| Name | What it does |
|---|---|
NEEDS |
API keys the agent requires: {"SPOON_API_KEY": "Free key at https://…"}. They’re listed on Connections, the card says Needs until they’re set, and the tools refuse to run without them. Read them inside the tool with os.environ["SPOON_API_KEY"], not at import time. |
OPTIONAL |
Keys it uses when they’re set but works without, in the same shape. |
CONNECTS |
Services it signs in to with OAuth: {"google": {"scopes": ["https://www.googleapis.com/auth/gmail.readonly"]}}. google and github are built in. The token arrives as os.environ["GOOGLE_ACCESS_TOKEN"] and is renewed before it expires. |
ASKS |
The label of a switch under the agent’s cog, like "Ask before each post", that lets the user turn off approvals. See below. |
VIEWS |
How to draw a tool’s result under the reply: a table, stats, cards, list or chart. See below. |
STARTERS |
Ready-made automations offered under the agent’s cog, with an Add button. |
LISTEN |
A function that watches something in the background (a feed, a socket, a mailbox), so automations can start when a message arrives. |
An agent’s tools can only see the keys it lists in NEEDS, OPTIONAL and CONNECTS. Others are hidden from os.environ while its code runs.
Asking before acting
Section titled “Asking before acting”Any tool that acts as the user (posts, sends, buys, deletes) should ask first:
from core.approval import approve
ASKS = "Ask before each post"
def post(text): approve("Post", f"Post publicly:\n\n> {text}") # waits for Approve; raises if skipped ...approve shows an Approve / Skip card in the chat (or in Activity, for an automation) and only returns once the user approves. If they skip, or nobody answers within ten minutes, it raises, and the model is told the action wasn’t done.
Instead of having the model retype data, VIEWS draws it straight from the tool’s result:
VIEWS = { "find_recipes": { "type": "table", "title": "Recipes", "rows": "recipes", "columns": [{"field": "name", "label": "Recipe"}, {"field": "minutes", "label": "Time", "unit": " min"}], },}Fields are paths into the result (forecast.0.high_c), and titles can include them ("Inbound to {airport.name}"). Keys starting with _ are for views only and are never sent to the model. Use them for data the model doesn’t need, like a month of prices for a chart.
Starter automations
Section titled “Starter automations”STARTERS = [ {"name": "Weekly meal plan", "description": "Sundays at 5, a plan for the week.", "trigger": {"type": "schedule", "cron": "0 17 * * sun"}, "steps": [{"kind": "agent", "name": "Plan", "agent": "self", "prompt": "Plan five weeknight dinners, vegetarian, under 30 minutes each."}]},]They’re written like an exported automation. "agent": "self" means this agent. Add saves a paused copy for the user to review.
Listening
Section titled “Listening”def LISTEN(hear, stop): while not stop.is_set(): for post in new_posts(): hear({"text": post["text"], "sender": post["author"], "chat": "#deploys", "kind": "group"}) stop.wait(60)Agent City runs LISTEN in its own thread while the agent is on and set up, and stops it when the agent is turned off, changed or removed. Each hear() is a message (text, and optionally sender, chat, kind and mentioned). If it raises, the card shows why and it’s restarted a minute later.
README.md
Section titled “README.md”Notes for people: what the agent does, what it needs, how to set it up. It’s shown when someone reviews the agent before installing it. When you rename an agent, its old name is replaced in both SOUL.md and README.md.
evals.json
Section titled “evals.json”Real questions the agent should handle, with the tool each should call. They’re run against a real model to check that a soul or model change didn’t break anything:
[ {"ask": "What can I make with eggs and spinach?", "expect_tools": {"find_recipes": "spinach"}}, {"ask": "Something vegan for dinner", "expect_tools": {"find_recipes": ""}}, {"agent": "recipes", "ask": "What's the weather?", "expect_no_tools": ["find_recipes"]}]expect_tools maps a tool to text its arguments should contain ("" for any). Other checks include expect_agents, expect_no_tools, expect_memory and expect_reply.
Trying it
Section titled “Trying it”The Agent builder creates two helpers in your data folder the first time it runs:
cd ~/Library/Application\ Support/Agent\ City./bin/new-agent recipes Basil # a new folder from the template, with TODOs./bin/try-tool recipes find_recipes '{"ingredients": "eggs"}' # call one tool directly, no model neededtry-tool also prints any view as text and warns about view fields that found nothing.
How Agent City loads an agent
Section titled “How Agent City loads an agent”- At startup, every folder with a
SOUL.mdandtools.pyis found, from the app, your sources and your own agents. tools.pyis imported, with the environment limited to the agent’s own keys.TOOLSandHANDLERSare checked against each other.- The soul’s
descriptionis added to the coordinator’s list, so My team can route to it straight away. - When it’s asked something, it runs the tool-calling loop with its soul, shared memory, its own memory and context, and its tools, plus
ask_agentto ask other agents for help.
Next: Host your own agent source.
