Skills

Execution lifecycle

Every skill in Ultron goes through the same engine. It acquires a concurrency slot, resolves the model, builds session context from the user profile and memories, assembles the tool catalog filtered by connected integrations, runs the tool-calling loop with parallel tool execution, streams progress to the caller, and writes a memory entry plus an activity log on completion.

Updated today

Overview

At a glance
Entry
runSkillMission(params) in src/lib/skills/engine.ts
Concurrency cap
2 parallel skill runs per process
Model timeout
180 seconds per request
Retry policy
Up to 2 retries on 429, backoff 2s then 4s
Default tier
Allegro
Tool execution
Parallel by default, up to N tool-use blocks per turn
Progress
Streamed through onProgress callback
Auto memory
save_memory called on completion summary
Activity feed
log_activity rows for every tool start and end

The engine is a single function with auxiliary helpers, not a class. Skills do not have hooks they can register or extend; the engine has the runtime authority. This is the price of the closed catalog: every skill behaves the same under load, every skill respects the same concurrency limit, and every skill gets the same progress callback shape. The body of the SKILL.md changes; the surrounding execution does not.

Entry point

One function, six parameters. The caller passes the skill id, the user objective, the user id, the workspace code, optional conversation context, and an optional progress callback.

src/lib/skills/engine.tsts
235export async function runSkillMission(params: {
236 skillId: string
237 objective: string
238 userId: string
239 workspaceCode: string
240 conversationContext?: string
241 onProgress?: SkillProgressCallback
242}): Promise<MissionResult>

The chat handler calls this function once per run_skill tool use. The objective is the user message that triggered the skill, possibly rewritten by the router. The conversation context is a compressed summary of the surrounding chat so the skill has a sense of what the user has been doing. The progress callback is how the activity sidebar and the chat surface learn what the skill is doing in real time.

Concurrency slot

The first thing the engine does is wait for a slot. A semaphore caps parallel skill runs at two per host process.

src/lib/skills/engine.tsts
87let _activeSkills = 0
88const _waitQueue: Array<() => void> = []
89
90async function acquireSlot(): Promise<void> {
91 if (_activeSkills < MAX_CONCURRENT_SKILLS) {
92 _activeSkills++
93 return
94 }
95 return new Promise<void>((resolve) => {
96 _waitQueue.push(() => { _activeSkills++; resolve() })
97 })
98}
99
100function releaseSlot(): void {
101 _activeSkills--
102 const next = _waitQueue.shift()
103 if (next) next()
104}
Note
The cap is per host. A containerized cloud runtime instance handles two skill runs at a time. The runtime scales out by booting more containers, each with its own counter. The cap protects against upstream model rate limits at the level of a single user agent token, not at the level of the whole tenant.

Model resolution

The skill declares a tier as a string. The engine resolves it to a versioned model id.

src/lib/skills/engine.tsts
34const MODEL_MAP: Record<string, string> = {
35 minuet: MINUET_MODEL_ID,
36 allegro: ALLEGRO_MODEL_ID,
37 forte: FORTE_MODEL_ID,
38}
39
40const MODEL_API_URL = process.env.MODEL_API_URL ?? ""
41const MAX_CONCURRENT_SKILLS = 2
42const MODEL_TIMEOUT_MS = 180_000 // 3 minutes for deep work
Tip
The model field on a manifest is a tier label, not a model id. Swapping the entire fleet to a newer model version is a one-line change to MODEL_MAP. Every running skill picks up the new id on its next request.

Session context

Before the loop starts, the engine builds a context block that sits at the top of the system prompt. It pulls user profile data and relevant memories.

Two reads happen here. First, the user profile is loaded from the profile cache (preferences, company, role, brand voice). Second, the memory layer runs hybrid retrieval: it combines meaning-based search with keyword search, then a small step picks the handful most relevant entries. The merged result lands in the system prompt under a USER CONTEXT heading the skill body can reference.

Note
Context retrieval runs before any tool call so the model knows what it knows before deciding what to fetch. A skill that calls search_memory as a first move is re-doing work the engine already did. Skills should treat the context block as authoritative for what the user has on file.

Tool catalog assembly

The catalog is built once at the start of the run and frozen for the duration of the loop. It is the manifest tools, unioned with the system tools, filtered by the connection gate.

src/lib/skills/engine.tsts
345// 1. Union the manifest tools with the always-on system tools
346const requested: ToolName[] = Array.from(
347 new Set([...skill.tools, ...SYSTEM_TOOLS])
348)
349
350// 2. Look up the user's connected providers
351const { data: rows } = await adminDb.from("integrations")
352 .select("provider")
353 .eq("user_id", userId)
354 .eq("status", "active")
355const connected = new Set((rows ?? []).map((r) => r.provider))
356
357// 3. Drop tools whose provider is not connected
358const allowed = requested.filter((name) =>
359 toolPassesConnectionGate(name, connected)
360)
361
362// 4. Resolve to tool-call schemas
363const tools = getToolDefinitions(allowed)
Warning
The connection gate is the second enforcement of the allow-list. Tools that fail it never serialize to the model. A skill that depends on a tool the user has not connected should handle the missing capability gracefully in its prompt, not assume the tool is always there.

The tool-calling loop

With model, prompt, and tools ready, the engine starts the tool-calling loop against the model. Each iteration is one turn.

  1. 01
    Send the message array
    The system prompt plus the user objective plus any tool results from prior turns. Tool catalog attached.
  2. 02
    Inspect stop reason
    The model returns end_turn, tool_use, max_tokens, or stop_sequence. Only tool_use continues the loop.
  3. 03
    Execute every tool-use block
    If the response contains N tool-use blocks, dispatch them in parallel. Collect the results in order.
  4. 04
    Append tool results
    One tool-role message per tool call. The order matches the original tool_use_id sequence.
  5. 05
    Decrement turn budget
    If maxTurns is reached, force a final answer with a short follow-up prompt and exit.
  6. 06
    Repeat or finalize
    Next iteration if stop reason was tool_use. Otherwise persist the final assistant message and exit.

Parallel tool execution

The engine fans out concurrent tool calls when the model returns multiple tool-use blocks in one response. This is the fast path for things like multi-search.

Most turns return a single tool call but it is common for the model to ask for several searches at once. The engine dispatches them in parallel through Promise.all against the tool executors, then assembles the results back into the message array in the same order the model requested them. Tool result order matters: the model reads results by their tool_use_id, not by array position, but a stable ordering helps the next turn read clean.

Tip
Tools that mutate shared state (writes to the same memory key, posts to the same channel) should be careful when scheduled in parallel. The engine does not serialize them. If a skill needs strict ordering, the prompt has to ask for one tool at a time.

Progress streaming

The caller passes an onProgress callback. The engine fires it on every state change inside the loop.

Event typeFired whenPayload
statusOn skill start, model switch, finalizationShort message
turnAt the top of each loop iterationTurn number, tool count
tool_startBefore each tool executor runsTool name, args summary
tool_endAfter each tool executor returnsTool name, duration, error?
errorOn unrecoverable failurePhase, message

The chat surface renders these events into the agent activity sidebar. Tool start and tool end events drive the running status pills shown next to each in-flight skill. The status events feed the short summary line at the top of an active session.

Rate limit retries

The engine wraps every model call in a retry helper that backs off on 429.

src/lib/skills/engine.tsts
114async function fetchWithRetry(
115 url: string,
116 init: RequestInit,
117 maxRetries = 2,
118): Promise<Response> {
119 for (let attempt = 0; attempt <= maxRetries; attempt++) {
120 const res = await fetch(url, init)
121 if (res.status === 429 && attempt < maxRetries) {
122 const delay = (attempt + 1) * 2000 // 2s then 4s
123 await new Promise((r) => setTimeout(r, delay))
124 continue
125 }
126 return res
127 }
128 return fetch(url, init)
129}
Note
Two retries is the cap. Anything beyond that surfaces as an error to the caller. The shape protects against single bursts without masking sustained rate-limit pressure.

Side effects on completion

When the loop ends with a final answer, the engine writes two records before returning to the caller.

  1. 01
    save_memory
    A summary entry is written to agent_memories tagged with the skill id and the objective. Subsequent skills retrieve it through the hybrid memory path.
  2. 02
    log_activity
    A row in the activity feed marking the skill as complete with elapsed time, turn count, and result length.
  3. 03
    releaseSlot
    The semaphore counter decrements and the next queued skill wakes up.
  4. 04
    Return MissionResult
    The caller receives the final assistant text, an array of canvas spec ids (if any were emitted), and a metrics block.

Error paths

A skill can fail in three places. Each surfaces differently.

FailureWhenWhat the user sees
Tool errorAn executor throws or returns a tool error resultThe model receives the error as a tool result and decides what to do. The skill usually retries or asks the user.
Loop exhaustionmaxTurns reached without end_turnThe engine forces a final answer prompt. The result is whatever the model could synthesize from the partial work.
Hard errorThe model provider returns a non-recoverable status or the network times outAn error progress event fires. The chat surface shows a retry affordance. The conversation state goes to failed.

File map

src/lib/skills
engine.tsrunSkillMission, acquireSlot, fetchWithRetry
registry.tsSKILL_MANIFEST + loadSkill
types.tsSkillMeta, SkillProgressEvent, MissionResult
memory.tscontext cascade + summary write
src/lib
chat-tools.tstool executors + toolPassesConnectionGate
agentMemory.tshybrid retrieval used during context build
src/app/api/chat
v2/route.tscalls runSkillMission via the run_skill tool
src/components
AgentActivitySidebar.tsxrenders progress events

Glossary

Mission
One full execution of a skill from acquireSlot to releaseSlot.
Slot
A unit of the concurrency semaphore. Two slots per host process.
Tier resolution
Mapping the manifest model string to a versioned model id through MODEL_MAP.
Session context
The block of profile data plus retrieved memories injected into the system prompt before the loop starts.
Allowed tool
A tool that survived both the manifest gate and the connection gate. Only allowed tools serialize to the model.
Progress event
One callback fire from the engine to the caller carrying status, turn, tool_start, tool_end, or error.
Mission result
The return value of runSkillMission. Contains the final text, canvas spec ids, and a metrics block.