Sub-agent Workflows
Once an agent can call another agent through an InvokeAgent tool, you have the primitive you need for multi-step workflows: research, summarisation, aggregation. The outer agent is the planner; specialists handle the legwork; everything reports back as structured JSON.
This page walks through three reference workflows. Each pairs a specialist agent (prompt + tools + model) with the tool wrapper that exposes it.
Architecture
Three principles hold across every workflow on this page:
- The planner is small and stateless. Use a fast model. Its job is to decide which specialist to call next, not to do the work.
- Specialists are deep and typed. Larger model, structured
OutputSchema, focused tool set. The result is something the planner can quote verbatim. - Identity flows everywhere.
scope.CurrentUseron the planner is theuserUIDpassed to the specialist is the user whose ACL filters every graph and search call.scope.CurrentChatpropagates the same way, so every sub-run is grouped under the parent chat.
Workflow 1 — Research with citations
A support assistant gets a complex question ("why is my MBA-2024 hot on battery?"). Instead of dumping search results into the chat, it delegates to a research specialist that runs multiple searches, picks evidence, and returns a ResearchBrief.
The specialist agent
[agent: Curiosity.Agents.Name("KB Research")]
[agent: Curiosity.Agents.Description("Cross-reference the KB and support cases to answer a technical question with citations.")]
[agent: Curiosity.Agents.ChatTask("01HQ…Sonnet")] // larger model for synthesis
[agent: Curiosity.Agents.OutputSchema("ResearchBrief")]
[agent: Curiosity.Agents.Tool("01J…SearchKB")]
[agent: Curiosity.Agents.Tool("01J…SimilarCases")]
[agent: Curiosity.Agents.Tool("01J…FetchArticle")]
You research support questions for ${PRODUCT_SKU:-the user's product}.
Step 1. Call SearchKB with the user's question to find candidate articles.
Step 2. Call SimilarCases with the user's question to find historically similar cases.
Step 3. For the top 3 candidates from step 1 or 2, call FetchArticle to read the body.
Step 4. Return a ResearchBrief with citations to the actual articles/cases you read.
Do NOT invent article IDs. If no evidence is found, return an empty brief.
The schema and the tools
[AgentOutputSchema]
public record ResearchBrief(
string Summary,
Finding[] Findings,
string[] CitedUIDs);
public record Finding(string Title, string Evidence, string SourceUID);
SearchKB — hybrid text + semantic search via CreateSearchAsUserAsync:
[Tool("Search the product knowledge base for articles matching a question. Returns scored snippets.")]
public static async Task<string> SearchKB(ToolScope scope,
[Parameter("The question in the user's own words.", required: true)]
string question,
[Parameter("Optional product SKU to scope the search.", required: false)]
string productSku = null,
[Parameter("Maximum number of results.", required: false)]
int limit = 10)
{
var req = SearchRequest.For(question);
req.BeforeTypesFacet = new HashSet<string> { N.KBArticle.Type };
req.HybridSearch = true;
req.SemanticRerank = true;
if (!string.IsNullOrWhiteSpace(productSku))
req.WithTargetQuery(() => scope.Graph.Q()
.StartAt(N.Product.Type, productSku)
.In(N.KBArticle.Type, E.AboutProduct));
var q = await scope.Graph.CreateSearchAsUserAsync(
req, scope.CurrentUser, scope.CancellationToken);
var hits = q.Take(limit).AsScoredUIDEnumerable()
.Select(s => {
scope.Graph.TryGetReadOnlyContent<KBArticle>(s.UID.UID, out var n);
var text = scope.ChatAI.GetTextFromNode(s.UID.UID, limit: 4_000);
var id = scope.AddSnippet(uid: s.UID.UID, text: text);
return new {
snippetId = id,
uid = s.UID.UID.ToString(),
title = n?.Title ?? "",
score = s.Score,
excerpt = text
};
})
.ToArray();
return hits.ToJson();
}
SimilarCases — vector search via IQuery.StartAtSimilarTextAsync:
[Tool("Find historically similar support cases by semantic similarity to a description. " +
"Use for 'have we seen this before?' questions.")]
public static async Task<string> SimilarCases(ToolScope scope,
[Parameter("The symptom or question.", required: true)]
string description,
[Parameter("Number of cases to return.", required: false)]
int count = 10)
{
var arctic = scope.Graph.Indexes
.OfType<SentenceEmbeddingsIndex>(N.SupportCase.Type)
.FirstOrDefault(i => i.SentenceEncoderModel == SentenceEncoderModel.ArcticXS);
if (arctic is null) return "{\"error\":\"No embeddings index on SupportCase.\"}";
var q = await scope.Graph.Q().StartAtSimilarTextAsync(
text: description,
count: count,
nodeTypes: new[] { N.SupportCase.Type },
indexUID: arctic.UID,
applyCutoff: true);
var results = q.AsScoredUIDEnumerable().Select(s => {
scope.Graph.TryGetReadOnlyContent<SupportCase>(s.UID.UID, out var c);
var snippetId = scope.AddSnippet(uid: s.UID.UID,
text: scope.ChatAI.GetTextFromNode(s.UID.UID, 2_000));
return new { snippetId, uid = s.UID.UID.ToString(),
subject = c?.Subject, score = s.Score };
}).ToArray();
return results.ToJson();
}
FetchArticle — pull the full body of a single node (cheap, deterministic):
[Tool("Fetch the full body of a knowledge-base article or case by UID.")]
public static string FetchArticle(ToolScope scope,
[Parameter("The node UID.", required: true)] string uid)
{
var nodeUID = UID128.Parse(uid);
var text = scope.ChatAI.GetTextFromNode(nodeUID, limit: 12_000);
var snippetId = scope.AddSnippet(uid: nodeUID, text: text);
return new { snippetId, uid, text }.ToJson();
}
The planner that calls it
The outer chat assistant only needs the InvokeAgent wrapper (or the dedicated KBResearchTool wrapper). One line in its prompt:
When the user asks a technical question that needs evidence,
delegate to the 'KB Research' specialist via InvokeAgent and quote
its ResearchBrief in your reply, preserving the citations.
Workflow 2 — Aggregation over the graph
Aggregations are deterministic by nature — they shouldn't go through an LLM. The agent's job is to pick an aggregation and narrate the result; the tool runs the actual IQuery traversal.
The aggregation tool
public record CategoryCount(string Category, int Count);
[Tool("Count support cases by category for a given time window. Returns a list of {category, count} " +
"pairs sorted descending. Use for 'how many', 'top N', or 'breakdown' questions.")]
public static async Task<string> CountCasesByCategory(ToolScope scope,
[Parameter("ISO-8601 start of the window (inclusive).", required: true)]
string from,
[Parameter("ISO-8601 end of the window (exclusive).", required: true)]
string to,
[Parameter("Optional product SKU to scope the count.", required: false)]
string productSku = null)
{
var fromTs = DateTimeOffset.Parse(from);
var toTs = DateTimeOffset.Parse(to);
// Start from all SupportCase nodes the caller can see, then traverse to Category.
var cases = scope.Graph.Q(scope.CurrentUser)
.StartAt(N.SupportCase.Type)
.Where(N.SupportCase.Opened, fromTs, toTs);
if (!string.IsNullOrWhiteSpace(productSku))
cases = cases.Out(N.Product.Type, E.AboutProduct)
.Where(N.Product.SKU, productSku)
.In(N.SupportCase.Type, E.AboutProduct);
var categoryCounts = cases
.Out(N.Category.Type, E.InCategory)
.AsEnumerable()
.GroupBy(n => n.GetString(N.Category.Name))
.Select(g => new CategoryCount(g.Key, g.Count()))
.OrderByDescending(c => c.Count)
.ToList();
scope.SetToolCallDisplayName($"Counted cases {fromTs:yyyy-MM-dd}…{toTs:yyyy-MM-dd}");
return categoryCounts.ToJson();
}
Wiring it up
Attach CountCasesByCategory to an Analyst specialist whose only job is to choose the right aggregation and report numbers in prose. The planner delegates via InvokeAgent("Analyst", "Top 5 case categories last week for SKU MBA-2024") and gets back a numerical breakdown plus a one-sentence summary.
Unknown component: alert Keep aggregation tools stateless and bounded — always require a time window or a UID list. An LLM happily asks "count all cases ever"; the tool should refuse or page. [!/alert]
Workflow 3 — Similarity + summarisation
A "customer digest" workflow: given a customer account, find similar accounts, pull their open cases, summarise common themes. Three tools, one planner, one summariser.
Similar accounts via IQuery.ToSimilarity
[Tool("Find accounts similar to a seed account using behaviour + product-mix signals. " +
"Returns up to N scored UIDs.")]
public static string SimilarAccounts(ToolScope scope,
[Parameter("Account UID of the seed.", required: true)]
string accountUid,
[Parameter("How many similar accounts to return.", required: false)]
int count = 25)
{
var seed = UID128.Parse(accountUid);
var scenario = scope.Graph.Q().StartAt(seed)
.ToSimilarity(options => options.MaxResults(count))
.AddSignal("by-product-mix", s => s
.FromQuery(q => q.StartAt(seed)
.Out(N.Product.Type, E.Purchased)
.In(N.Account.Type, E.Purchased))
.Weight(2.0))
.AddSignal("by-segment", s => s
.FromQuery(q => q.StartAt(seed)
.Out(N.Segment.Type, E.InSegment)
.In(N.Account.Type, E.InSegment))
.Weight(1.0));
var results = scenario.Run(scope.CurrentUser).Take(count);
return results.Select(r => new {
uid = r.UID.ToString(),
score = r.Score
}).ToArray().ToJson();
}
Open cases for an account
[Tool("List open support cases for an account, newest first.")]
public static string OpenCases(ToolScope scope,
[Parameter("The account UID.", required: true)]
string accountUid,
[Parameter("Maximum number of cases.", required: false)]
int limit = 20)
{
var accUID = UID128.Parse(accountUid);
var cases = scope.Graph.Q(scope.CurrentUser)
.StartAt(accUID)
.Out(N.SupportCase.Type, E.AboutAccount)
.Where(N.SupportCase.Status, "Open")
.SortBy(N.SupportCase.Opened, descending: true)
.Take(limit)
.AsEnumerable()
.Select(n => new {
uid = n.UID.ToString(),
subject = n.GetString(N.SupportCase.Subject),
opened = n.Get<DateTimeOffset?>(N.SupportCase.Opened)
});
return cases.ToArray().ToJson();
}
The Summariser specialist
A specialist that takes a list of UIDs (cases, articles, anything text-bearing) and returns a themed summary:
[agent: Curiosity.Agents.Name("Summariser")]
[agent: Curiosity.Agents.Description("Summarise a list of nodes by recurring themes.")]
[agent: Curiosity.Agents.ChatTask("01HQ…Sonnet")]
[agent: Curiosity.Agents.OutputSchema("ThemedSummary")]
[agent: Curiosity.Agents.Tool("01J…FetchArticle")]
You receive a JSON array of UIDs. For each UID, call FetchArticle to read
the content. Group the bodies into 2–5 themes; return a ThemedSummary
with a one-line description of each theme and the UIDs that supplied it.
Do not include UIDs you did not actually read.
[AgentOutputSchema]
public record ThemedSummary(Theme[] Themes);
public record Theme(string Title, string Description, string[] UIDs);
The planner
A tiny prompt drives the whole workflow:
For a 'digest of customer X' request:
1. Call SimilarAccounts(accountUid=X) to find peers.
2. For X and its top 5 peers, call OpenCases.
3. Pass the combined case UID list to the Summariser via
InvokeAgent('Summariser', JSON-of-UIDs).
4. Report the ThemedSummary back to the user, citing each theme's UIDs.
The planner runs on a small model; the heavy reading happens in the Summariser, against a larger model. Each step's tool result is small (UIDs, counts), so prompt budget stays predictable.
Cross-cutting patterns
Bounding cost
A naive planner can call InvokeAgent in a loop. Always:
- Constrain the planner's tool catalog. One
InvokeAgentper specialty, no generic "call any agent". - Set a hard cap in the prompt ("You may delegate at most 3 sub-tasks.").
- Wrap the outer endpoint in
Poolingmode (see Creating Endpoints).
Caching deterministic sub-results
Research and aggregation results are often re-usable across users in the same role. Persist them as graph nodes keyed by the input:
var cacheKey = $"research:{question.ToLowerInvariant().Hash()}";
if (scope.Graph.TryGetReadOnlyContent<_CachedResearch>(cacheKey, out var cached)
&& cached.Created > DateTimeOffset.UtcNow.AddHours(-1))
return cached.Brief;
The cache is graph-native, ACL-aware (drop entries the caller can't see), and re-usable across the planner, the front-end, and scheduled tasks.
Observability
Every RunAgentAsync call writes an _AgentRun node. Passing chatUID: scope.CurrentChat already groups parent and child runs under the same chat — that's the cheapest way to reconstruct a call tree. For workflows that span multiple chats (or no chat at all), tag the inner call with the outer run's UID through a variable:
variables["PARENT_RUN"] = parentRunUID.ToString();
A scheduled task can then walk _AgentRun nodes by PARENT_RUN to render call trees in your monitoring dashboard.
Failure modes
| Failure | What to do |
|---|---|
| Sub-agent returns malformed JSON | The outer model sees the failure as a tool error and can retry or fall through. Don't catch. |
| Sub-agent times out (5 min default) | The outer call fails too. Either raise the timeout for the specialist, or split it further. |
Recursive call (A invokes A) |
Not detected by the runtime. Encode the rule in the wrapper's allowed-names list. |
| Permission mismatch (user can't see specialist) | Wrapper returns { error: "Unknown agent…" }. Plan for it in the outer prompt. |
Checklist for shipping a sub-agent workflow
- Planner uses a small model (
Haiku-class) and a focused tool catalog. - Each specialist pins its own
ChatTaskUID. - Each specialist has an
OutputSchema— results are structured, not prose. - Tools at every level call
CreateSearchAsUserAsyncandGraph.Q(scope.CurrentUser)— never the admin variants. - Wrappers thread
chatUID: scope.CurrentChatthroughscope.AgentAI.RunAgentAsyncso parent/child runs stay grouped. - The outer endpoint is in
Poolingmode with a sensible upper bound on sub-agent calls. - Recursive invocation is explicitly disallowed in the wrapper.
See also
- Calling from an AI Tool — the
InvokeAgentwrapper this page builds on. - Creating Agents — specialist agent definition.
- Searching from Endpoints —
CreateSearchAsUserAsyncdetails. - IQuery Similarity Search — vector search inside tools.
- Similarity Engine —
ToSimilarityscenarios used by SimilarAccounts. - Graph Query Language — full
IQueryreference for aggregation tools.