| name | refine-node |
| description | Refine node definitions - determines HOW each node is implemented (SDKs, libraries, input/output structures, tools, guidelines). |
Refine Node
Refine node definitions in existing node and agent files. While create-workflow determines WHAT each node does, this phase determines HOW:
- Input/Output structures - Exact typed schemas (field names, types)
- Implementation approach - Which SDKs, libraries, or APIs to use
- Tools - For agent nodes, which tools they need
- Guidelines - Behavioral guidelines for agent nodes
Usage
/crayon:refine-node <workflow-name>
/crayon:refine-node <workflow-name> <node-name>
- With just workflow name: refines all unrefined nodes in the workflow
- With node name: refines only that specific node
Process
Step 1: Load and Assess
Read src/crayon/workflows/<workflow-name>.ts and parse its description field to find all task nodes. Then read each referenced node/agent file and check its description field.
A node needs refinement if its inputSchema / outputSchema are still empty z.object({}).
Step 2: Research Implementation Approaches
Before drafting, gather the information needed:
-
For nodes that interact with external systems (Salesforce, HubSpot, Slack, etc.):
- Invoke
/crayon:integrations to determine which SDK/library/API to use
- For listed integrations: read the specific file (e.g.,
salesforce.md)
- For unlisted systems: read
unlisted.md and research the best option
- CRITICAL — Connection Gate: For each integration the node needs:
- Call
get_connection with the integration ID, workflow name, and node name. If it succeeds, the connection is already assigned — proceed with implementation using the returned config.
- If it fails (no connection assigned), call
list_connections for that integration to see all available connections.
- If connections exist: Present them to the user and ask which one to use. Then call
assign_connection to map the chosen connection to the workflow/node. Finally call get_connection again to retrieve provider-specific config (e.g., Salesforce instance_url).
- If no connections exist: You MUST stop immediately. Do NOT create SDK files, client code, integration directories, or any implementation that depends on a live connection. Tell the user which connections are missing. If
list_connections returned a credentials_page_url, tell the user to visit that URL to add a connection. Otherwise tell them to open the Dev UI Credentials page. Then say "continue" when ready.
-
For agent nodes, check AI SDK provider docs for available tools:
- OpenAI: https://ai-sdk.dev/providers/ai-sdk-providers/openai
- Anthropic: https://ai-sdk.dev/providers/ai-sdk-providers/anthropic
- Use
WebFetch to read these pages for provider tool options
- IMPORTANT: When an agent uses a provider (OpenAI, Anthropic, etc.), declare it in
integrations: ["openai"] so the framework fetches the API key at runtime via ctx.getConnection(). Do NOT rely on env vars like OPENAI_API_KEY. The agent executor automatically detects the model provider in the integrations list and calls ctx.getConnection() to get the key.
-
For simple compute nodes: determine if any libraries are needed
Step 3: Draft All Refinements
Draft the complete refined definition for every node that needs it, then update all files at once.
For each node, determine:
- Implementation approach — SDK, library, or "pure TypeScript"
- Zod input schema — derived from the Input Description
- Zod output schema — derived from the Output Description
- Tools (agent nodes only) — selected from the three categories below
- Guidelines (agent nodes only) — behavioral rules, preferred sources, edge case handling
Use your judgment to propose reasonable schemas and tool selections based on the descriptions. The user can correct anything after.
Side-Effect Node Safety
When refining a node that performs side effects (sends emails/messages, updates database records, calls write APIs, modifies external state):
-
Targets must be intentional — The recipient, channel, record ID, or destination must be either an explicit input field OR a deliberate constant with clear rationale. Never randomly choose or fabricate a target. If the target isn't obvious from the node description, ask the user — don't guess.
-
Output schema MUST include action details — Every side-effect node must return what it did (or would have done), regardless of whether the action was actually performed:
messageSent: z.string().describe("The message that was sent")
recipientEmail: z.string().describe("The email address the message was sent to")
fieldsUpdated: z.record(z.string(), z.unknown()).describe("The fields that were updated and their new values")
testMode: z.boolean().describe("Whether the node ran in test mode (action was skipped)")
-
Guidelines for agents with side-effect tools — If an agent uses tools that have side effects, add explicit guidelines:
- "Only send messages to the channel/recipient specified in the input"
- "Never modify records other than those explicitly identified in the input"
Test Mode Support
Side-effect nodes must support test mode via ctx.testMode. When ctx.testMode is true, the node should:
- Skip the actual side effect (do not send the email, do not post to Slack, do not write to the database)
- Still return the full output schema with all action details filled in (what WOULD have been done)
- Include
testMode: ctx.testMode in the output
Example pattern for side-effect nodes:
execute: async (ctx, inputs) => {
const message = `Summary for ${inputs.recipientName}: ...`;
if (!ctx.testMode) {
await slackClient.postMessage({ channel: inputs.slackChannel, text: message });
}
return {
messageSent: message,
channelName: inputs.slackChannel,
recipientName: inputs.recipientName,
testMode: ctx.testMode,
};
},
The key insight: the output schema is the SAME in both modes. The node always returns what it did (or would have done). Test mode only skips the external call.
Tool Categories (Agent Nodes)
| Category | Description | Examples |
|---|
| Built-in nodes | Ships with crayon | webRead |
| Provider tools | From AI SDK providers (see import below) | openai.tools.webSearch(), openai.tools.codeInterpreter() |
| User nodes | Custom nodes in src/crayon/nodes/ | enrichCompany, sendSlackMessage |
Common mappings:
| Need | Tool | Category |
|---|
| Fetch web pages | webRead | builtin |
| Search the web | openai.tools.webSearch() | provider |
| Run Python code | openai.tools.codeInterpreter() | provider |
| Domain-specific (CRM, email) | User must implement | user node |
Provider tool imports: Provider tools require creating a provider instance from @ai-sdk/openai (NOT from crayon or openai):
import { createOpenAI } from "@ai-sdk/openai";
const openai = createOpenAI();
What to Update in Node Files
For each node/agent file (src/crayon/nodes/<name>.ts or src/crayon/agents/<name>.ts), update:
- The
description field — add typed schemas and (for agents) tools/guidelines:
Refined node description:
<Expanded description>
**Implementation:** <SDK, library, or approach>
**Input Description:** <original from create-workflow>
**Output Description:** <original from create-workflow>
Refined agent description:
<Expanded description>
**Implementation:** <SDK, library, or approach>
**Tools needed:**
- webRead (builtin)
- openai.tools.webSearch() (provider)
- myCustomNode (user node in src/crayon/nodes/my-custom-node.ts)
**Guidelines:** <specific guidelines>
**Input Description:** <original from create-workflow>
**Output Description:** <original from create-workflow>
-
The inputSchema and outputSchema — replace empty z.object({}) with proper Zod types
-
For agents: the integrations array — add the model provider (e.g. "openai", "anthropic") and any external services the agent needs. This is how the framework knows to fetch API keys at runtime via ctx.getConnection().
-
For agents: the tools record — add tool imports and entries based on **Tools needed:**
-
For agents: the spec file (src/crayon/agents/<name>.md) — update guidelines, output format sections, and optionally add model and maxSteps to the YAML frontmatter:
model: openai/gpt-4o — override the default model (use when a specific model is needed, e.g. cheaper model for simple tasks, stronger model for complex reasoning)
maxSteps: 10 — max tool-call iterations (increase for agents that need many sequential tool calls, decrease for simple single-shot agents)
Step 4: Write and Continue
After writing all refinements:
- Save a version — Call the
create_version MCP tool with a message like:
Refine nodes: <workflow-name>
<Describe what changed — like a good git commit message body.>
- Tell the user the node files have been updated
- Invoke
/crayon:compile-workflow to regenerate the workflow's run() method with proper types
Worked Example
A workflow has an enrich-lead agent with empty schemas from create-workflow:
Before (src/crayon/agents/enrich-lead.ts):
export const enrichLead = Agent.create({
name: "enrich-lead",
integrations: ["openai"],
description: `
Searches the web to find additional professional info about a lead.
**Input Description:** A lead's name, email, and any known company or title from Salesforce.
**Output Description:** Enriched lead info including LinkedIn URL, company, job title, and summary.
`,
inputSchema: z.object({}),
outputSchema: z.object({}),
tools: {},
specPath: path.resolve(__dirname, "./enrich-lead.md"),
});
After refinement — schemas filled, description enriched, tools added:
import { createOpenAI } from "@ai-sdk/openai";
const openai = createOpenAI();
export const enrichLead = Agent.create({
name: "enrich-lead",
integrations: ["openai"],
description: `
Given a lead's name, email, and any existing Salesforce fields, searches the web to find
additional information: LinkedIn profile URL, current company, job title, and other
relevant professional details.
**Implementation:** OpenAI GPT-4o with web search tool
**Tools needed:**
- openai.tools.webSearch() (provider)
**Guidelines:** Search by name + email domain or company. Prioritize LinkedIn as primary source. Do not fabricate URLs.
**Input Description:** A lead's name, email, and any known company or title from Salesforce.
**Output Description:** Enriched lead info including LinkedIn URL, company, job title, and summary.
`,
inputSchema: z.object({
name: z.string().describe("Lead's full name"),
email: z.string().describe("Lead's email address"),
company: z.string().nullable().describe("Company from Salesforce, if known"),
title: z.string().nullable().describe("Title from Salesforce, if known"),
}),
outputSchema: z.object({
name: z.string(),
email: z.string(),
linkedinUrl: z.string().nullable().describe("LinkedIn profile URL if found"),
company: z.string().nullable().describe("Current company if found"),
jobTitle: z.string().nullable().describe("Current job title if found"),
summary: z.string().describe("Brief summary of findings"),
}),
tools: {
webSearch: openai.tools.webSearch({}),
},
specPath: path.resolve(__dirname, "./enrich-lead.md"),
});
What changed:
- Description — expanded with Implementation, Tools needed, Guidelines
- inputSchema — filled with typed fields derived from Input Description
- outputSchema — filled with typed fields derived from Output Description
- tools —
webSearch added based on Tools needed
Principles
- Draft first, ask later — propose complete schemas based on descriptions; let the user correct rather than interrogating
- Preserve descriptions — keep the original Input/Output Description fields from create-workflow
- Concrete types — every field needs a type; no
any or untyped fields
- Research before guessing — check integration skills and provider docs before selecting tools/SDKs
- Side effects require intentional targets — never let a side-effect node target an ambiguous or fabricated recipient/destination. The target must be a required input field or deliberate constant.