Build a support assistant
This guide builds a customer-support chat assistant for a fictional online plant shop, Fernleaf. The assistant answers questions from a policy sheet, looks up orders through a function you provide, and hands the conversation to a person when it should. Along the way it covers the decisions that matter for any support bot: where the API key lives, how to write the instructions, how to stream replies to a web page, and how to test the result.
You will need: an API key, Node.js 18 or later for the server, and a few minutes to adapt the policy text to your own business.
Is an assistant the right fit?
A model-based assistant works well when:
- many questions are variations on the same few topics (shipping, returns, order status);
- answers can be grounded in written policy or in data you can look up;
- a wrong answer can be caught: there is a clear route to a person for anything sensitive.
It is a poor fit when every case needs judgment that is not written down anywhere, or when the cost of a single wrong answer is very high and there is no review step.
Architecture
Customer's browser ──▶ Your server ──▶ Syntic API (POST /v1/chat/completions)
◀── (holds the key, ◀──
runs tools,
stores chats)Never put a private API key (sk_live_…) in browser code or a mobile app. Anyone can read it there. The browser talks to your server; your server talks to the Syntic API. Public keys (pk_live_…) are refused by the chat endpoint.
Your server is also the right place to run tools, because it can reach your order database and enforce who may see which order.
Before writing code
List the jobs the assistant should do
For Fernleaf: answer policy questions, report order status, start a return, and hand over to a person. Write down what it must not do too: give plant-care diagnoses for sick plants, promise refunds outside policy, or discuss other customers.
Write the reference material
Gather the facts the assistant may use into one text block: shipping times, return window, contact hours. Keep it short and factual; the model will quote from it.
Define success
Pick measurable targets before building, for example: 90% of a test set of 40 real questions answered correctly, zero answers that contradict policy, and every refund request outside policy handed to a person.
Choose a model
Start with Amara S1.2: it handles conversation and tool calls well. Try Amara Quick later if your test set shows it is accurate enough, since it is cheaper per token. See Choosing a model.
The instructions
The system message does most of the work. Put fixed content first (role, rules, reference material) so it is cached across requests; see Pricing.
SYSTEM = """
You are the customer support assistant for Fernleaf, an online shop selling house plants.
How to behave:
- Be friendly and brief: two to four sentences unless the customer asks for detail.
- Answer only from the POLICY section or from tool results. If neither covers the question,
say you are not sure and offer to connect the customer with a person.
- Never invent order details. Use lookup_order for anything about a specific order.
- Before calling lookup_order, you need the order number (format FL-12345) and the email
address on the order. Ask for whichever is missing.
- Call hand_over when the customer asks for a person, is upset, reports a damaged delivery
older than 14 days, or asks for anything outside policy.
- Do not give advice about diagnosing or treating sick plants; suggest the care guides at
fernleaf.example/care instead.
POLICY
Shipping: orders ship within 2 business days; delivery takes 3-5 business days in the EU.
Returns: unopened items within 30 days of delivery, refunded to the original payment method.
Damaged plants: report within 14 days with a photo for a free replacement.
Support hours: Monday-Friday, 09:00-17:00 CET.
""".strip()For the Node.js server below, put the same text in prompt.js as a template string exported under the name SYSTEM.
Tips for this kind of prompt:
- State limits as rules, not hopes. “Answer only from POLICY or tool results” is easier to follow than “try to be accurate”.
- Name the escalation triggers. A model hands over more consistently when you list the situations.
- Give the input formats. Telling it what an order number looks like reduces wasted tool calls.
More on prompt writing is in Prompting tips.
The tools
// tools.js
export const TOOLS = [
{
type: 'function',
function: {
name: 'lookup_order',
description: 'Status, items and dates for one order. Requires the order number and the email on the order.',
parameters: {
type: 'object',
properties: {
order_number: { type: 'string', description: 'Order number such as FL-12345' },
email: { type: 'string', description: 'Email address used on the order' },
},
required: ['order_number', 'email'],
},
},
},
{
type: 'function',
function: {
name: 'hand_over',
description: 'Pass the conversation to a human agent with a one-line summary of the issue.',
parameters: {
type: 'object',
properties: { summary: { type: 'string' } },
required: ['summary'],
},
},
},
]
// Replace with real implementations. Check the email matches before returning anything.
export async function runTool(name, args, session) {
if (name === 'lookup_order') {
const order = await db.orders.find(args.order_number)
if (!order || order.email.toLowerCase() !== String(args.email).toLowerCase()) {
return { error: 'No order matches that number and email.' }
}
return { status: order.status, shipped_on: order.shippedOn, items: order.items.map((i) => i.name) }
}
if (name === 'hand_over') {
await helpdesk.createTicket({ chatId: session.id, summary: args.summary })
session.handedOver = true
return { ok: true, message: 'A team member will reply by email within one business day.' }
}
return { error: `Unknown tool ${name}` }
}The email check in lookup_order is the important part: the model passes along whatever the customer typed, so your code, not the model, decides whether the customer may see that order.
The server
The server keeps each conversation, runs the tool loop, and streams the final answer to the browser. Tool rounds run with stream: false (they are short and need complete arguments); the final reply is streamed so the customer sees text appear right away.
// server.js (node server.js)
import http from 'node:http'
import { TOOLS, runTool } from './tools.js'
import { SYSTEM } from './prompt.js'
const API = 'https://app.syntic.ai/v1/chat/completions'
const HEADERS = { Authorization: `Bearer ${process.env.SYNTIC_API_KEY}`, 'Content-Type': 'application/json' }
const MODEL = process.env.SUPPORT_MODEL ?? 'Amara S1.2'
const sessions = new Map() // use a database in production
async function complete(body) {
const res = await fetch(API, { method: 'POST', headers: HEADERS, body: JSON.stringify({ model: MODEL, ...body }) })
if (!res.ok) throw Object.assign(new Error('api_error'), { status: res.status, body: await res.text() })
return res
}
async function reply(session, userText, out) {
session.messages.push({ role: 'user', content: userText })
// Tool rounds: not streamed.
for (let round = 0; round < 4; round++) {
const res = await complete({ stream: false, max_tokens: 600, tools: TOOLS, messages: session.messages })
const message = (await res.json()).choices[0].message
if (!message.tool_calls?.length) break
session.messages.push(message)
for (const call of message.tool_calls) {
const result = await runTool(call.function.name, JSON.parse(call.function.arguments || '{}'), session)
session.messages.push({ role: 'tool', tool_call_id: call.id, content: JSON.stringify(result) })
}
}
// Final answer: streamed to the browser as plain text.
const res = await complete({ stream: true, max_tokens: 600, tool_choice: 'none', tools: TOOLS, messages: session.messages })
const decoder = new TextDecoder()
let buffer = ''
let text = ''
for await (const bytes of res.body) {
buffer += decoder.decode(bytes, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop()
for (const line of lines) {
if (!line.startsWith('data: ') || line === 'data: [DONE]') continue
const event = JSON.parse(line.slice(6))
if (event.error) throw new Error(event.error) // stream_error
const piece = event.choices?.[0]?.delta?.content
if (piece) { text += piece; out.write(piece) }
}
}
session.messages.push({ role: 'assistant', content: text })
}
http.createServer(async (req, res) => {
if (req.method !== 'POST' || req.url !== '/chat') return res.writeHead(404).end()
let raw = ''
for await (const chunk of req) raw += chunk
const { chatId, text } = JSON.parse(raw)
const session = sessions.get(chatId) ?? { id: chatId, messages: [{ role: 'system', content: SYSTEM }] }
sessions.set(chatId, session)
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' })
try {
await reply(session, text, res)
} catch (err) {
console.error(err.status, err.body ?? err.message)
res.write('\nSorry, something went wrong on our side. Please try again in a moment.')
}
res.end()
}).listen(3000)A few details worth noticing:
- The final request passes
toolstogether withtool_choice: "none". Keeping the tool list identical on every request keeps the prompt prefix cacheable, while"none"guarantees a text answer. - The streamed text is collected and stored as the assistant turn, so the next question has full context.
- Errors are logged on the server with their status and body, and the customer sees a plain apology rather than an error code.
The browser side
The page only needs to post the message and print the streamed text:
<div id="log"></div>
<form id="f"><input id="q" autocomplete="off" /><button>Send</button></form>
<script>
const chatId = crypto.randomUUID()
document.getElementById('f').onsubmit = async (e) => {
e.preventDefault()
const q = document.getElementById('q')
const line = document.createElement('p')
document.getElementById('log').append(Object.assign(document.createElement('p'), { textContent: 'You: ' + q.value }), line)
const res = await fetch('/chat', { method: 'POST', body: JSON.stringify({ chatId, text: q.value }) })
q.value = ''
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader()
for (let r = await reader.read(); !r.done; r = await reader.read()) line.textContent += r.value
}
</script>Long conversations
Each request sends the whole conversation. To keep cost and size in check:
- Cap a session at, say, 30 turns, then offer to start fresh or hand over.
- Shorten tool results before storing them (status and dates, not full order records).
- For very long chats, replace the oldest turns with a short summary produced by a separate request, keeping the system message untouched at the start.
Limits that apply are listed on Limits: 500 messages and about 2 MB per request.
Test before launch
Build a test set from real (anonymized) questions, each with the expected behavior:
| Input | Expected |
|---|---|
| ”How long does delivery take to Spain?” | Mentions 3-5 business days after dispatch within 2 business days. |
| ”Where is my order FL-20391?” | Asks for the email address before looking up. |
| ”My fern arrived broken three weeks ago.” | Calls hand_over (outside the 14-day window). |
| ”Why are the leaves on my monstera yellow?” | Declines diagnosis, points to the care guides. |
| ”What did the last customer order?” | Refuses. |
Run the whole set after every prompt or model change and compare against your success targets. Keep failing cases in the set permanently so fixed problems stay fixed.
Running it
- Usage limits: a support bot is customer-facing, so use the wrapper from Handle usage limits and show a friendly message when the API is paused.
- Monitoring: log
x-syntic-request-idper turn, the number of hand-overs, and the share of conversations that end in a hand-over. - Review: read a sample of real conversations every week. New failure patterns become new test cases.
Related pages
- Write a tool loop: the loop mechanics in more detail.
- Streaming: the event format used for the final answer.
- Route support tickets: sort incoming email before it reaches a person.