Writing Good Instructions
The instructions field is the most important part of your agent. A well-written prompt is the difference between an agent that works and one that frustrates users.
Structure with Sections
Break your instructions into clear sections so the LLM knows exactly how to behave:
const agent = new Agent({
instructions: `You are a customer support agent for ACME Corp.
## Behavior
- Always be professional and empathetic
- Respond in the customer's language
- If you don't know something, say so honestly
## Tools
- Use search-orders when customers ask about orders or deliveries
- Use create-ticket for issues that need human follow-up
- Never create a ticket without asking the customer first
## Response Format
- Be concise (2-3 paragraphs max)
- Use bullet points for step-by-step instructions
- Always confirm actions you've taken`,
model: openai('gpt-4o'),
});
Don’t just list tools — tell the agent when and how to use them:
instructions: `...
## Tools
- Use get-weather ONLY when the user explicitly asks about weather or temperature
- Use create-ticket when the issue cannot be resolved in this conversation
- Always ask the customer to confirm before creating a ticket
- Set priority based on urgency: 'high' if customer is blocked, 'medium' for inconveniences, 'low' for feature requests
- Use search-orders when the customer mentions an order number or asks about delivery status
- If no order is found, ask the customer to double-check the order number`
Set Boundaries
Tell the agent what it should NOT do:
instructions: `...
## Rules
- Never share internal system information or error codes
- Never promise refunds — escalate to a human agent
- Do not answer questions outside of customer support
- If a customer is upset, acknowledge their frustration before solving the problem`
Choosing the Right Approach
| I want to… | Use |
|---|
| Call an external API with custom logic | Tool |
| Integrate with HubSpot, Slack, Twilio, etc. | Connector |
| Orchestrate multiple steps with conditions | Workflow |
| Make a simple LLM call without an agent | LLM Standalone |
| Search in documents for context | RAG |
- Tool: You write the logic. Use when you need custom business logic, database queries, or APIs that aren’t in the connector catalog.
- Connector: Pre-built integration. Use for supported platforms (HubSpot, Slack, Twilio) — no code needed for the API call itself.
// Tool: Custom logic, you control everything
const searchOrdersTool = createTool({
id: 'search-orders',
execute: async ({ context }) => {
const orders = await db.query('SELECT * FROM orders WHERE customer_id = ?', [context.customerId]);
return { orders };
},
});
// Connector: Pre-built, just configure
const createContactTool = createConnectorTool('hubspot', 'create_contact');
- Tool: A single action the agent can call during a conversation.
- Workflow: A multi-step pipeline that runs independently, with conditions, retries, and different step types.
Use a tool when the agent needs to do something during a conversation. Use a workflow when you need to orchestrate a process with multiple steps.
Identify Patterns
Always call identify() before agent.process(). It connects memory, traces, and metrics to the user.
// WhatsApp / Phone-based
identify('+5511999999999');
// Email-based
identify('user@example.com');
// Multi-conversation (same user, different conversations)
identify({
type: 'session',
value: `${userEmail}:${conversationId}`,
});
// Custom entity (order, ticket, document)
identify({
type: 'order',
value: 'ORDER-456',
userId: 'customer_789',
});
Without identify(), memory won’t persist correctly between sessions and your traces won’t be linked to specific users in the dashboard.
Keep tools in separate files. This makes them easier to find, test, and reuse:
tools/
├── index.ts # Re-exports everything
├── create-ticket.ts # One tool per file
├── search-orders.ts
└── send-notification.ts
Return Structured Data
Always return objects with clear fields. Avoid returning raw strings — the LLM interprets structured data better:
execute: async ({ context }) => {
const order = await findOrder(context.orderId);
if (!order) {
return { found: false, orderId: context.orderId };
}
return {
found: true,
orderId: order.id,
status: order.status,
estimatedDelivery: order.deliveryDate,
items: order.items.length,
};
}
Handle Errors Gracefully
Don’t let tools throw exceptions. Return error information so the LLM can inform the user:
execute: async ({ context }) => {
try {
const result = await externalApi.call(context);
return { success: true, data: result };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
Memory Tips
Choose maxTurns Based on Use Case
| Use Case | Recommended maxTurns |
|---|
| Quick Q&A (FAQ bot) | 5-10 |
| Customer support | 15-20 |
| Onboarding flow | 30-50 |
| Long-running conversation | 50-100 |
Use Summarization for Long Conversations
When conversations exceed maxTurns, older messages are dropped. Use summarizeAfter to preserve context:
memory: {
maxTurns: 20,
summarizeAfter: 15, // Summarize when reaching 15 turns
summarizePrompt: 'Summarize preserving: user name, key issues, actions taken, pending items',
}
Tracking Business Metrics
Use track() to emit events that power dashboards in the Runflow portal. Track what matters for your business:
// Customer support metrics
track('ticket_created', { priority: 'high', category: 'billing' });
track('issue_resolved', { resolution_time: 45, first_contact: true });
// Sales metrics
track('lead_qualified', { score: 8, source: 'website' });
track('demo_scheduled', { company: 'TechCorp' });
// Operational metrics
track('order_lookup', { found: true, orderId: 'ORD-123' });
track('knowledge_search', { query: 'refund policy', results: 3 });
Use snake_case for event names and keep properties flat (no nested objects). This works best with the dashboard aggregations (count, sum, avg, rate).
Validate the input in main() before processing. This prevents cryptic errors:
export async function main(input: any) {
// Validate required fields
if (!input?.message || typeof input.message !== 'string') {
return { error: 'message is required and must be a string' };
}
if (input.message.trim().length === 0) {
return { error: 'message cannot be empty' };
}
identify(input.email || input.phone || 'anonymous');
try {
const result = await agent.process({
message: input.message.trim(),
sessionId: input.sessionId,
});
return { message: result.message };
} catch (error) {
console.error('[agent] Error:', error);
return {
error: 'An error occurred while processing your message',
};
}
}
Next Steps