Skip to content

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 Agent with beingId: null is ephemeral — created for one project’s team, never a citizen.
  • An Agent with a real beingId is 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/agents let 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:

RoleNameVoice
leadOrin ValeCalm, decisive, names trade-offs out loud
researcherIone MarshPrecise and curious; never states a fact she can’t cite
writerSol AndersWarm, vivid, concrete; cuts every needless word
editorMara QuillKind but unsparing; explains every change in one line
engineerTobiah ReedTerse and practical; comments on why, not what
designerLys ArdenVisual 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 }
Terminal window
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.

Webweb.search · web.fetch · web.research · http.request

Filesfiles.list · files.read · files.write · files.search

Codecode.execute · code.scaffold_react_node · scene3d.create

Docsdocs.write_markdown · docs.write_docx

Imageimage.generate

Datamath.evaluate

LLMllm.generate · llm.structured

Memorymemory.recall · memory.store

Desktopsignal.raise · schedule.create

Commsemail.read · email.send · telegram.send · whatsapp.send

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