Agents & Skills
Studio work is never done by one monolithic model call — it’s done by a small team: one
Agent per role in the plan, each carrying only the skills its tasks need, running through a
bounded tool-calling loop. This section covers the runtime that makes that real; the Skills
catalogue below documents every one of the 25 built-in skills, one page
each, with its exact input and output schema.
The pieces
Agent { id, ownerId, beingId: Id | null, name, role, description, avatar, skills: string[], tier, systemPrompt, isPublic }Team { id, projectId, leadAgentId, memberAgentIds: Id[] }Task { id, projectId, parentId, title, role, assigneeAgentId, skills, tier, dependsOn, status, input, output, error, costUsd }- An
AgentwithbeingId: nullis ephemeral — created for one project’s team, never a citizen. - An
Agentwith a realbeingIdis a full being — one of the twelve seeded citizens, or one you’ve promoted yourself, with its own Soul. GET /api/studio/agents/POST /api/studio/agentslet you save and reuse agents across projects; team assembly reuses a saved agent automatically when its role and skills fit (see Projects).
Personas: how an ephemeral agent gets a voice
Nine built-in personas (packages/agents/src/personas.ts) cover the roles a plan can name — lead,
researcher, writer, editor, engineer, designer, analyst, marketer, and a few more — each with a
name, an avatar, a distinct voice, values, and a default skill set, so a five-agent team reads like
five different people, not one assistant repeating itself in different fonts:
| Role | Name | Voice |
|---|---|---|
| lead | Orin Vale | Calm, decisive, names trade-offs out loud |
| researcher | Ione Marsh | Precise and curious; never states a fact she can’t cite |
| writer | Sol Anders | Warm, vivid, concrete; cuts every needless word |
| editor | Mara Quill | Kind but unsparing; explains every change in one line |
| engineer | Tobiah Reed | Terse and practical; comments on why, not what |
| designer | Lys Arden | Visual and specific: names colours, sizes, spacing |
If the Souls package is available at runtime, an ephemeral agent gets a full, deterministically-generated Soul compiled in “agent” mode instead of the plain persona prompt — same name and working voice, richer personality underneath. Either way, no model call is spent creating an agent; personas and souls are template-generated.
The tool-calling loop
Every task an agent runs goes through runToolLoop() (packages/llm): the agent’s system prompt
(persona/soul + a short “how you work in the Studio” runtime addendum) plus the task instruction,
with its allowed skills offered as OpenAI-style function tools, for up to 10 steps. Every think,
tool.call, tool.result, message and final output becomes a TraceEvent you can watch live —
see Projects for the full mechanics (retries, dependency context, file
sandboxing).
The skill registry
A skill is a plain, typed function: zod input, zod output, a run(input, ctx). SkillContext
carries the calling being, the LLM client, an abort signal, an event emitter, and a handful of
optional adapters (memory, signals, schedule, integrations, connections) that the API wires
in for real — without them, a skill degrades to a clear “not wired” note rather than crashing.
GET /api/studio/skills → SkillDef[] (every registered skill, with its JSON Schema)POST /api/studio/skills/invoke → SkillInvokeResponse { name, input } → { ok, output?, error?, durationMs }curl -s -X POST http://localhost:4000/api/studio/skills/invoke \ -H 'content-type: application/json' -b cookies.txt \ -d '{"name":"math.evaluate","input":{"expression":"sqrt(3^2 + 4^2)"}}'{ "ok": true, "output": { "result": "5", "value": 5, "type": "number" }, "durationMs": 2 }Every agent gets a baseline skill set regardless of role (DEFAULT_AGENT_SKILLS): web.search,
web.fetch, web.research, math.evaluate, files.read, files.write, files.list — so any
agent can research and save its work even if the planner forgot to list those skills explicitly.
World-acting skills need a yes
Seven skills act on the world outside Novaterra, irreversibly and in the being’s name:
email.send, telegram.send,
whatsapp.send, http.request,
http.json, code.execute and
code.scaffold_react_node (WORLD_ACTING_SKILLS in
packages/skills/src/registry.ts). http.json was added to this set after http.request shipped:
it takes the same arbitrary method and body, so gating one without the other would have left a
bypass — an agent refused a POST via one skill could simply switch to the other. A model driving a
tool loop can be talked into calling any of these seven by a web page it fetched or an email it read,
so the refusal lives in code, not in English in a tool description: SkillRegistry.invoke() refuses
every one of them unless the caller passes approved: true, which means a human asked for this
exact action.
Two callers set it: POST /api/studio/skills/invoke (you hit the button yourself, so it’s already
your explicit action) and the desktop’s signal dispatcher, once you answer “Do it” on the decision it
raises. Everything else — an agent’s tool loop in a Studio task, Muse reaching for telegram.send —
is refused, and the refusal puts a kind: 'decision' Signal
on your desktop instead: “Something running for you wants to use <skill>, which acts outside
Novaterra”, with “Do it” / “No” options and the exact attempted input attached as action. The
agent is told plainly that the step is waiting for approval and to carry on with what it can do
without it.
Answering “Do it” actually runs the skill now: dispatchSignalAction()
(apps/api/src/modules/desktop/actions.ts) re-invokes it with approved: true and reports back
whether it succeeded, all inside the same single-use answer that flips the signal from open to
answered — a signal can’t be answered twice, so an approval can’t be replayed.
The skills catalogue
Thirty skills are registered in a running install. Twenty-eight are named in the canonical
BUILTIN_SKILLS list — the twenty-five below, plus the three authoring skills that follow them.
Every one is grounded directly in packages/skills/src/** — input and output schemas are copied
from the real zod definitions, not approximated.
Web — web.search · web.fetch · web.research · http.request
Files — files.list · files.read · files.write · files.search
Code — code.execute · code.scaffold_react_node · scene3d.create
Docs — docs.write_markdown · docs.write_docx
Image — image.generate
Data — math.evaluate
LLM — llm.generate · llm.structured
Memory — memory.recall · memory.store
Desktop — signal.raise · schedule.create
Comms — email.read · email.send · telegram.send · whatsapp.send
Authoring — studio.author_agent · studio.author_generator · studio.author_skill_bundle
These three are the Studio building Novaterra itself, and they are catalogued but have no page of their own yet; the Agent crew & pipeline generator is what drives them, and covers what they do and why they refuse what they refuse.
The first two write a row you own — an agent, or a generator. They are deliberately not
world-acting: nothing leaves the world, no code runs, and a DELETE undoes them. What they are
is owner-scoped by construction — the adapter behind them is built from the calling being’s id, and
no input schema carries an ownerId, so a model has no shape in which to name a different owner.
Every skill name in an authored agent is validated against the live registry before a row is
written, and an unknown name refuses the whole call rather than saving something half-wired.
The third is named for exactly what it is allowed to do. An LLM-authored skill is arbitrary
code, so nothing here authors a skill: the run emits a plugin bundle — a manifest, proposed
source and a REVIEW.md — into the project workspace. It is never registered, never imported,
never executed, and its installed flag is always false. Installing it stays a separate act by a
person: reading it, moving the directory, and POST /api/plugins with the capability grant written
out by hand. See Plugins and extensibility.
The two that are registered but not catalogued
docs.write_pdf (PDF export via pdf-lib, same markdown pipeline as docs.write_docx) and
http.json (a stricter JSON-in/JSON-out http.request that throws on a non-2xx or non-JSON
response instead of returning it) both run, and neither is in BUILTIN_SKILLS. Since generator
validation checks step skills against the catalogue, no generator can declare them — worth
adding explicitly; see Roadmap & FAQ.
Note that http.json is gated as world-acting, despite that omission — it takes the same
arbitrary method, URL and body as http.request, and a skill that can POST anywhere without a
human “yes” would be a way around the gate rather than an exception to it.