GuidesBuild a coding assistant

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

ModelUse it for
Syntic CodeReading code, writing patches and running tool loops. The default for this guide.
Syntic Code FastThe same work when wall-clock time matters more than price (for example, a review that blocks a merge).
Amara S1.2Explaining 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/passwd is refused.
  • No arbitrary shell. git grep and 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 usage on 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_file prefixes each line with its number, so the review can cite src/cart.ts:118 accurately.
  • 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.md

Store 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.

Was this page helpful?