Guild User Control Plane Tips & Tricks

A guild that doesn't support its own is a poor guild indeed. Let it never be said that our Guild does anything less.
Whether you're a new initiate or just a curious admirer, this guide will arm you with some of our favorite tips and tricks to help you get the most out of Guild's agent control plane.
And as Guild, evolves this article will too.
If you like what you see, bookmark the tab and drop by periodically to see what our AI charmers have cooking.
Calling an Agent as a Tool
An agent has a defined input type and output type – which is exactly the interface a tool needs to satisfy. An agent’s /tool sub-package exposes that interface directly, allowing you to call any Guild agent just like any other tool.
To use another agent as a tool, add the agent as a dependency in you agent’s package.json:
"@guildai/waterson~subagent": "^1.0.0"Then import from its /tool sub-package and include it in your agent’s tools object.
import subagentTool from "@guildai/waterson~subagent/tool" const tools = { subagent: subagentTool,}
Now you can call it just like any other tool…
From an llmAgent…
export default llmAgent({ description: "my cool agent", tools: { subagent: subagentTool, }})
From a TypeScript agent with automatically-managed state…
const result = await task.tools.subagent({ type: "text", text: "sudo make me a sandwich" })From a TypeScript agent with self-managed state…
// In your agent's start or onToolResults handler...return callTools([{ type: "tool-call", toolName: "subagent", toolCallId: "subagent-1", input: { type: "text", text: "sudo make me a sandwich" }}]) // ...and then receive a TypedToolResult in your onToolResults handler// with the agent's output type.async onToolResults(results: Array<TypedToolResult<Tools> | TypedToolError<Tools>>, task: Task<Tools, State>) { const res = results.find((r) => r.type === "tool-result" && r.toolName === "subagent") if (res) { const { output } = res as TypedToolResult<Tools["subagent"]> ... } ...}
External prompts (import the text, Luke)
Don’t embed long prompts as strings in your code — import them as files instead.
// agents.tsimport { llmAgent } from "@guildai/agents-sdk"import systemPrompt from "./system-prompt.md" export default llmAgent({ description: "My cool agent", systemPrompt, tools: { /* some tools */ },})
Then write your prompt in a standalone system-prompt.md:
<!-- system-prompt.md -->You are a helpful assistant that... [...1000 lines elided...] Good luck!
Why import?
Your editor treats .md files as first-class citizens: syntax highlighting, preview, spell-check. Long prompt strings buried in TypeScript get none of that, and they make the surrounding logic harder to read.
As a bonus, you can use a templating library like Mustache to inject values into your prompt at runtime — something that’s awkward to do with raw template literals in code.
Structured input for llmAgent
Use inputSchema and inputTemplate to control what your llmAgent accepts as input.
const inputSchema = z.object({ action: z.literal("labeled"), label: z.object({ name: z.string() }), issue: z.object({ number: z.number() }), repository: z.object({ owner: z.object({ login: z.string() }), name: z.string(), }),}) const inputTemplate = `Issue #{{issue.number}} in {{repository.owner.login}}/{{repository.name}} hasjust been assigned the "{{label.name}}" label. Should we care?` export default llmAgent({ description, systemPrompt, tools, inputSchema, inputTemplate,})
The example above handles a GitHub webhook. inputSchema is a zod schema describing the fields you care about — you don’t need to model the whole payload, just the parts you’ll use. inputTemplate is a mustache-style string that formats those fields into the first user message the LLM sees.
Why bother?
By default, an LLM agent expects text: { type: "text", text: string }. That’s fine when a human is typing, but awkward when another agent or a webhook is calling yours.
Without inputSchema and inputTemplate, you end up begging callers in your agent’s description to format their input a certain way — and then hoping the LLM can figure out what they actually sent. With a webhook, you don’t even get that: the entire raw JSON blob gets splatted in and you’re at the mercy of the model to interpret it correctly.
Defining inputSchema makes your agent a first-class tool: callers know exactly what to provide, and the input arrives structured and ready to use.
Structure Your Input (and Output)
Define inputSchema and outputSchema so a caller knows exactly what input your agent needs – and what output the caller will get back.
import { agent } from "@guildai/agents-sdk"import { z } from "zod" const inputSchema = z.object({ to: z.string().email().describe("Recipient email address"), subject: z.string().describe("Email subject line"), body: z.string().describe("Email body text"),}) const outputSchema = z.object({ messageId: z.string().describe("The sent message's ID"),}) export default agent({ description: "Sends an email.", inputSchema, outputSchema, tools: {}, start: async ({ to, subject, body }) => { const { id } = await sendEmail({ to, subject, body }) return output({ messageId: id }) },})
The problem with parsing text
Real code in the wild, I promise:
// Please don't do this.start: async (input) => { const to = input.text.match(/TO:\s*(.+)/)?.[1] const subject = input.text.match(/SUBJECT:\s*(.+)/)?.[1] const body = input.text.match(/BODY:\s*([\s\S]+)/)?.[1] if (!to || !subject || !body) throw new Error("missing required fields") ...}
This puts the burden on the caller to format text correctly and on the agent to parse it correctly. Both sides can fail silently: a caller might omit a field and only find out when the agent crashes at runtime; the agent might silently match the wrong thing if the format is off by a character.
Using inputSchema eliminates both failure modes. The Guild runtime validates input against the schema before start ever fires. If a required field is missing or the wrong type, the task fails cleanly before your code runs.
A quick zod cheat-sheet
While the outputSchema can be any Zod type, the inputSchema requires you to provide a z.object to be compatible with LLM tool calling conventions:
z.object({ name: z.string(), count: z.number().int().positive(), flag: z.boolean().optional(), priority: z.enum(["low", "medium", "high"]), tags: z.array(z.string()), nested: z.object({ id: z.number() }),})
Chain .describe("…") on any field to document it — that description shows up in the JSON schema exposed to callers, so write it as if you’re writing a tool parameter description (because you are).
z.object({ to: z.string().email().describe("Recipient address; must be a valid RFC 5321 address"),})
If your agent doesn’t take any parameters at all, pass an empty Zod object:
z.object({})outputSchema matters too
const outputSchema = z.object({ messageId: z.string(), timestamp: z.string().datetime(),}) // Compiler error if you forget a field or use the wrong type.return output({ messageId: id, timestamp: new Date().toISOString() })
As a special case, note that an llmAgent currently has an output schema of:
const outputSchema = z.object({ type: z.literal("text"), text: z.string().describe("The output text")})
We plan to provide facilities for non-text content and structured output in the future.
Your agent is a tool
Because an agent’s schema is identical to a tool’s schema, any Guild agent can be used as a tool by another agent — and the inputSchema you define becomes the tool’s parameter schema. A caller agent (or a human reading the docs) sees exactly what fields to provide, with descriptions, types, and constraints.
If you accept raw text and parse it with regex, a caller agent has to guess the format from your description and hope for the best. With a structured schema, the format is unambiguous and machine-readable.
See Calling an Agent as a Tool for how to wire up an agent as a tool in another agent.
More on LLM agents
An llmAgent defaults to accepting { type: "text", text: string } — which is fine when a human is typing. But if your LLM agent is called by another agent or a webhook, define inputSchema and inputTemplate to accept structured input and render it into the first user message automatically.
See Structured input for llmAgent for details.
Test Faster with a Pre-Built Bundle
Want to test faster? Pass --bundle to guild agent test to skip server-side compilation entirely.
npm run bundleguild agent test --bundle agent.js.gz --mode json <<-EOF{ "type": "text", "text": "this is my agent input" }EOF
If you’re starting from a new agent template, this is all you need to do: the necessary build steps and dependencies are baked-in.
But if you’ve got an older agent, there are some quick things you’ll need to update:
First, add esbuild as a dev dependency.
npm install --save-dev esbuildThen add a bundle script to your agent’s package.json that compiles, bundles, and encodes the output.
"scripts": { "build": "npm run build:compile && npm run build:transform && npm run build:copy", "build:compile": "tsc --build", "build:transform": "babel ./dist/agent.js --out-dir ./dist --out-file-extension .compiled.js --plugins @guildai/babel-plugin-agent-compiler", "build:copy": "cp *.md dist/", "bundle": "npm run build && esbuild dist/agent.compiled.js --bundle --loader:.md=text --platform=node --format=esm --external:zod --external:@guildai/agents-sdk | gzip | base64 > agent.js.gz"}
⚠️ Important! You need to make sure that your build step still produces the same artifacts that it did before; the bundle step should be separate and work after the build!
Why
Normally, guild agent test uploads your source and the server must compile it. With --bundle, you push a ready-to-test artifact that skips the npm install and npm build steps.
Using the Coding Agent
The coding agent runs your instructions inside an isolated container. Use it when you need an agent that can read and write files, run shell commands, or work with a cloned repository.
import { ExperimentalCodingTools as codingTools } from "@guildai-services/guildai~experimental-coding"import { type Task, agent } from "@guildai/agents-sdk"import { CONTAINER_IMAGE } from "@guildai/guildai~sys-experimental-coding"import codingAgentTool from "@guildai/guildai~sys-experimental-coding/tool" const tools = { ...codingTools, communicate: codingAgentTool }type Tools = typeof tools async function run(input: Input, task: Task<Tools>): Promise<Output> { const { container_id } = await task.tools.experimental_coding_create({ image: CONTAINER_IMAGE }) try { const { text } = await task.tools.communicate({ container_id, message: "What files are in the current directory?", }) return { type: "text", text } } finally { await task.tools.experimental_coding_delete({ container_id }) }}
Container lifecycle
You own the container. Create it before you need it, and always clean up in a finally block so you don’t leave containers running if your agent throws.
const { container_id } = await task.tools.experimental_coding_create({ image: CONTAINER_IMAGE })try { // ... do work ...} finally { await task.tools.experimental_coding_delete({ container_id })}
Giving the coding agent a system prompt
Pass a system_prompt to communicate to shape how the coding agent behaves. Keep it in a separate .md file (see the external prompts tip).
import systemPrompt from "./system-prompt.md" const { text } = await task.tools.communicate({ container_id, system_prompt: systemPrompt, message,})
Giving the coding agent tools
The container is sandboxed — it can’t reach external services on its own. Pass tools explicitly using codingAgentToolsFrom:
import { pick } from "@guildai/agents-sdk"import { gitHubTools } from "@guildai-services/guildai~github"import { codingAgentToolsFrom } from "@guildai/guildai~sys-experimental-coding" const { text } = await task.tools.communicate({ container_id, message, tools: codingAgentToolsFrom({ ...pick(gitHubTools, [ "github_issues_create", "github_pulls_create", // ... ]), }),})
Only pass the tools the coding agent actually needs for the task at hand.
GitHub tools reference
Interacting with GitHub from inside the container requires specific tool combinations. Here’s what you need for common tasks:
| Task | Required Tools |
|---|---|
| Clone a repository | github_repos_download_zipball_archive |
| Compute a diff on a branch | github_pulls_list_files |
| Create a branch | github_git_get_ref, github_git_create_ref |
| Create a commit | github_git_get_ref, github_git_get_commit, github_git_create_blob, github_git_create_tree, github_git_create_commit, github_git_update_ref |
| Create a PR | github_pulls_create |
| Create an issue | github_issues_create |
| Comment on an issue or PR | github_issues_create_comment |
Multi-turn conversations
By default, each communicate call starts a fresh session. To continue a conversation across multiple calls — for example, a setup step followed by a main task — capture the session_id from the first response and pass it back:
import Mustache from "mustache" // Step 1: set up the environmentconst { text: setupResult, session_id } = await task.tools.communicate({ container_id, message: Mustache.render(setupTemplate, { owner, repo, pull_number }), tools: codingAgentToolsFrom({ ...pick(gitHubTools, ["github_repos_download_zipball_archive"]) }),}) // Step 2: do the actual work, continuing the same sessionconst { text } = await task.tools.communicate({ container_id, session_id, message: Mustache.render(instructionsTemplate, { owner, repo, pull_number }), tools: codingAgentToolsFrom({ ...pick(gitHubTools, ["github_pulls_list_files"]) }),})
Using session_id preserves the container’s working directory, environment variables, and conversation history between calls.
The complete agent lifecycle.
No credit card required.