Skip to content

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 handle

The 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.

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.

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.0
author: Ann Lee
role: Recipes & cooking
examples: What can I make with eggs and spinach? | A vegetarian dinner for four
---
# Basil
## Who I am
I'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.

  • 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.

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 json
import 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.

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.

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.

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.

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.

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.

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.

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.

The Agent builder creates two helpers in your data folder the first time it runs:

Terminal window
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 needed

try-tool also prints any view as text and warns about view fields that found nothing.

  1. At startup, every folder with a SOUL.md and tools.py is found, from the app, your sources and your own agents.
  2. tools.py is imported, with the environment limited to the agent’s own keys. TOOLS and HANDLERS are checked against each other.
  3. The soul’s description is added to the coordinator’s list, so My team can route to it straight away.
  4. 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_agent to ask other agents for help.

Next: Host your own agent source.