Build a coding assistant
This guide builds a small code-review bot for a team repository. Given a branch, it reads the diff, opens files it needs for context, runs the test suite, and posts a review that separates real bugs from style remarks. Along the way it covers what makes software tasks different from chat: grounding in real files, letting the model run commands safely, and keeping long sessions affordable.
If you want a ready-made coding agent in your terminal rather than building one, use Syntic Code.
You will need: an API key in SYNTIC_API_KEY, Node.js 18 or later, git, and a repository with a test command.
Pick the model
| Model | Use it for |
|---|---|
Syntic Code | Reading code, writing patches and running tool loops. The default for this guide. |
Syntic Code Fast | The same work when wall-clock time matters more than price (for example, a review that blocks a merge). |
Amara S1.2 | Explaining code to non-engineers, writing release notes from commit lists. |
Prices and plan requirements are on Models overview. All of them share the 262,144-token context window.
Give the model tools, not a pasted repository
Pasting a whole repository into the prompt is slow, expensive and usually unnecessary. Instead, send the diff and offer a few read-only tools. The model then opens only what it needs, the same way a reviewer would.
// tools.js
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import fs from 'node:fs/promises'
import path from 'node:path'
const run = promisify(execFile)
const ROOT = path.resolve(process.env.REPO_DIR ?? '.')
function inside(p) {
const full = path.resolve(ROOT, p)
if (!full.startsWith(ROOT + path.sep)) throw new Error('path outside repository')
return full
}
export const TOOLS = [
{
type: 'function',
function: {
name: 'read_file',
description: 'Read a file from the repository. Returns at most 400 lines starting at start_line.',
parameters: {
type: 'object',
properties: { path: { type: 'string' }, start_line: { type: 'integer', minimum: 1 } },
required: ['path'],
},
},
},
{
type: 'function',
function: {
name: 'search_code',
description: 'Search the repository for a literal string. Returns up to 50 matching lines as path:line: text.',
parameters: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] },
},
},
{
type: 'function',
function: {
name: 'run_tests',
description: 'Run the project test suite and return the last 150 lines of output and the exit code.',
parameters: { type: 'object', properties: {} },
},
},
]
export async function runTool(name, args) {
if (name === 'read_file') {
const lines = (await fs.readFile(inside(args.path), 'utf8')).split('\n')
const start = Math.max(1, args.start_line ?? 1)
return lines.slice(start - 1, start + 399).map((l, i) => `${start + i}: ${l}`).join('\n')
}
if (name === 'search_code') {
const { stdout } = await run('git', ['grep', '-n', '-F', '--', args.query], { cwd: ROOT }).catch((e) => e)
return (stdout ?? '').split('\n').slice(0, 50).join('\n') || 'no matches'
}
if (name === 'run_tests') {
const r = await run('npm', ['test', '--silent'], { cwd: ROOT, timeout: 300_000 }).catch((e) => e)
const out = `${r.stdout ?? ''}${r.stderr ?? ''}`.split('\n').slice(-150).join('\n')
return `exit code ${r.code ?? 0}\n${out}`
}
return `unknown tool ${name}`
}Three safety properties are built in:
- Paths are confined to the repository;
../../etc/passwdis refused. - No arbitrary shell.
git grepand the test command are fixed; the model chooses arguments, not programs. If you later add a general shell tool, run it in a throwaway container with no network and no secrets. - Outputs are capped. A 20,000-line log would fill the context and cost money while adding nothing.
The review loop
// review.js (REPO_DIR=../my-repo node review.js main feature/new-checkout)
import { execFileSync } from 'node:child_process'
import { TOOLS, runTool } from './tools.js'
const [base, head] = process.argv.slice(2)
const diff = execFileSync('git', ['diff', `${base}...${head}`], { cwd: process.env.REPO_DIR, encoding: 'utf8' })
const SYSTEM = `
You review pull requests for a TypeScript web shop.
Use the tools to read surrounding code and to run the tests before concluding.
Report in Markdown with three sections:
## Bugs: problems that would cause wrong behaviour, each with file:line and a suggested fix.
## Risks: missing tests, unclear error handling, security concerns.
## Nits: naming and style, at most five.
Say "None" for an empty section. Do not report something as a bug unless you have read the code that proves it.
`.trim()
async function chat(messages) {
const res = await fetch('https://app.syntic.ai/v1/chat/completions', {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.SYNTIC_API_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ model: 'Syntic Code', stream: false, max_tokens: 4000, tools: TOOLS, messages }),
})
const data = await res.json()
if (!res.ok) throw new Error(`${res.status} ${data.error}: ${data.detail ?? data.message ?? ''}`)
return data
}
const messages = [
{ role: 'system', content: SYSTEM },
{ role: 'user', content: `Review this diff (${base}...${head}):\n\n<diff>\n${diff}\n</diff>` },
]
let tokens = 0
for (let step = 0; step < 25; step++) {
const data = await chat(messages)
tokens += data.usage.total_tokens
const msg = data.choices[0].message
messages.push(msg)
if (!msg.tool_calls?.length) {
console.log(msg.content)
console.error(`\n${step + 1} requests, ${tokens} tokens`)
process.exit(0)
}
for (const call of msg.tool_calls) {
let result
try {
result = await runTool(call.function.name, JSON.parse(call.function.arguments || '{}'))
} catch (err) {
result = `error: ${err.message}`
}
messages.push({ role: 'tool', tool_call_id: call.id, content: String(result) })
}
}
console.error('Stopped after 25 steps without a final review.')
process.exit(1)Things to notice:
- Tool errors go back to the model as results, not as crashes. A wrong path produces “error: path outside repository”, and the model corrects itself on the next step.
- A hard step cap (25 here) prevents an endless loop when the model keeps searching.
- Token use is tracked from
usageon every response, so you can see what one review costs. See Count cost from usage.
Prompting for code work
- Ask for evidence. “Do not report something as a bug unless you have read the code that proves it” sharply cuts false alarms in reviews.
- Fix the output format. A stable structure (Bugs, Risks, Nits) is easy to post as a pull-request comment and easy to compare between runs.
- Give line numbers.
read_fileprefixes each line with its number, so the review can citesrc/cart.ts:118accurately. - State the stack. “TypeScript web shop” stops suggestions for the wrong framework.
Going further: proposing patches
To let the assistant suggest fixes, add a propose_patch tool whose argument is a unified diff. Do not apply it automatically. Instead, write it to a branch, run the tests, and attach the patch to the review for a person to accept:
{
type: 'function',
function: {
name: 'propose_patch',
description: 'Suggest a fix as a unified diff against the head branch. The patch is reviewed by a person; it is not applied automatically.',
parameters: {
type: 'object',
properties: { title: { type: 'string' }, diff: { type: 'string' } },
required: ['title', 'diff'],
},
},
}Validate the diff with git apply --check before showing it; a patch that does not apply goes back to the model as a tool error.
Long sessions
A review that opens many files can grow past 100,000 tokens. To keep cost down:
- Shorten old tool results once they have been used: replace a 400-line file read with “read src/cart.ts (lines 1-400)” after the model has moved on.
- Keep the system message and tool list identical between requests so the start of the prompt is served from cache.
- Split very large pull requests per directory and review each part separately.
Server-side clearing of old tool results is planned; see Context management.
Run it in CI
# .github/workflows/review.yml (excerpt)
- name: AI review
env:
SYNTIC_API_KEY: ${{ secrets.SYNTIC_API_KEY }}
REPO_DIR: ${{ github.workspace }}
run: node review/review.js origin/${{ github.base_ref }} HEAD > review.mdStore the key as a repository secret, never in the workflow file. Use a key created for CI only, so it can be revoked without affecting anything else; see Managing API keys.