---
title: "Your first agent"
description: "Build an issue-triage agent with the AI SDK from an empty folder, and approve each GitHub write from the terminal."
canonical_url: "https://github-tools.com/learn/first-agent"
---
# Your first agent

> Build an issue-triage agent with the AI SDK from an empty folder, and approve each GitHub write from the terminal.

To build a GitHub agent, give a model the GitHub tools for one job and run it in a loop that asks you before every write. In this tutorial you build that loop in a terminal: an issue-triage agent that reads a repository's issues, proposes labels and comments, and waits for you to type `y` before it changes anything.

## What you'll build

A Node.js script you chat with in the terminal. You name a repository, ask something like "label the open issues that have no label", and the agent reads the issues with `listIssues` and `getIssueContext`. When it wants to call a write tool such as `addLabels`, the script prints the tool name and input, and asks `y/n`.

Along the way you'll see the three parts every agent has: a model, a [preset](https://github-tools.com/presets) of tools, and the loop that answers [approval](https://github-tools.com/approval) requests.

## Before you start

- Node.js 22 or later.
- A GitHub repository you can test on, with a few open issues. Use one you own: the agent will add labels and comments once you approve them.
- An [AI Gateway](https://vercel.com/docs/ai-gateway) API key. The model string `anthropic/claude-opus-5.5` is routed through AI Gateway. To use a provider package such as `@ai-sdk/anthropic` instead, pass its model object where the tutorial passes the string.

## 1. Create the project

Create an empty folder with a `package.json`:

```json [triage-agent/package.json]
{
  "name": "triage-agent",
  "private": true,
  "type": "module",
  "scripts": {
    "ask": "tsx --env-file=.env ask.ts",
    "agent": "tsx --env-file=.env agent.ts"
  }
}
```

`"type": "module"` lets the scripts use top-level `await`. The two scripts run TypeScript files with [tsx](https://tsx.is) and load `.env` first.

## 2. Install the packages

From the `triage-agent` folder, install the SDK, the AI SDK, Zod, and tsx:

<code-group>

```bash [pnpm]
pnpm add @github-tools/sdk ai zod
pnpm add -D tsx
```

```bash [npm]
npm install @github-tools/sdk ai zod
npm install -D tsx
```

```bash [yarn]
yarn add @github-tools/sdk ai zod
yarn add -D tsx
```

```bash [bun]
bun add @github-tools/sdk ai zod
bun add -d tsx
```

</code-group>

## 3. Add your credentials

Create a [fine-grained personal access token](https://github.com/settings/personal-access-tokens/new) limited to your test repository. The `issue-triage` preset needs these repository permissions:

<table>
<thead>
  <tr>
    <th>
      Permission
    </th>
    
    <th>
      Access
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      Issues
    </td>
    
    <td>
      Read and write
    </td>
  </tr>
  
  <tr>
    <td>
      Contents
    </td>
    
    <td>
      Read
    </td>
  </tr>
  
  <tr>
    <td>
      Pull requests
    </td>
    
    <td>
      Read
    </td>
  </tr>
  
  <tr>
    <td>
      Metadata
    </td>
    
    <td>
      Read, added automatically
    </td>
  </tr>
</tbody>
</table>

Put the token and your AI Gateway key in `.env`, and keep that file out of version control:

```bash [triage-agent/.env]
GITHUB_TOKEN=github_pat_xxxxxxxxxxxx
AI_GATEWAY_API_KEY=xxxxxxxxxxxx
```

The SDK reads `GITHUB_TOKEN` when it creates the tools. The AI SDK reads `AI_GATEWAY_API_KEY` when it calls the model.

## 4. Ask a first question

Before building the agent, check that the model can reach your repository. Create `ask.ts`:

```ts [triage-agent/ask.ts]
import { createGithubTools } from '@github-tools/sdk'
import { generateText, stepCountIs } from 'ai'

const { text } = await generateText({
  model: 'anthropic/claude-opus-5.5',
  tools: createGithubTools({ preset: 'issue-triage' }),
  stopWhen: stepCountIs(5),
  prompt: `List the open issues on ${process.argv[2]} and say which ones have no label.`,
})

console.log(text)
```

`createGithubTools({ preset: 'issue-triage' })` returns the 32 tools for triaging issues. `stopWhen: stepCountIs(5)` lets the model take up to five steps: without it, `generateText` stops after the first tool call and returns no answer.

Run it with your repository in place of `your-name/your-repo`:

<code-group>

```bash [pnpm]
pnpm ask your-name/your-repo
```

```bash [npm]
npm run ask -- your-name/your-repo
```

```bash [yarn]
yarn ask your-name/your-repo
```

```bash [bun]
bun run ask your-name/your-repo
```

</code-group>

You should see a short list of your open issues, with the unlabeled ones called out. The model called `listIssues`, read the result, and answered.

If you see `github_tools.TOKEN_REQUIRED`, `.env` wasn't loaded: run the command from the `triage-agent` folder. `github_tools.NOT_FOUND` usually means the token can't see the repository. [Errors](https://github-tools.com/errors) lists the other codes.

## 5. Build the agent

`ask.ts` answers one question and can't write anything: nothing in it answers an approval request. The agent keeps a conversation going, and asks you about each write. Create `agent.ts`:

```ts [triage-agent/agent.ts]
import { createInterface } from 'node:readline/promises'
import { createGithubAgent } from '@github-tools/sdk'
import type { ModelMessage, ToolApprovalResponse } from 'ai'

const [owner, repo] = (process.argv[2] ?? '').split('/')
if (!owner || !repo) {
  console.error('Usage: tsx --env-file=.env agent.ts <owner>/<repo>')
  process.exit(1)
}

const agent = createGithubAgent({
  model: 'anthropic/claude-opus-5.5',
  preset: 'issue-triage',
  context: { owner, repo },
})

const terminal = createInterface({ input: process.stdin, output: process.stdout })
const messages: ModelMessage[] = []

while (true) {
  const prompt = await terminal.question('\nYou: ')
  if (prompt.trim() === 'exit') break
  messages.push({ role: 'user', content: prompt })

  let result = await agent.generate({ messages })
  messages.push(...result.responseMessages)

  let requests = result.content.filter(part => part.type === 'tool-approval-request')
  while (requests.length > 0) {
    const answers: ToolApprovalResponse[] = []
    for (const request of requests) {
      const { toolName, input } = request.toolCall
      const answer = await terminal.question(`Allow ${toolName} ${JSON.stringify(input)}? (y/n) `)
      answers.push({ type: 'tool-approval-response', approvalId: request.approvalId, approved: answer.trim() === 'y' })
    }
    messages.push({ role: 'tool', content: answers })

    result = await agent.generate({ messages })
    messages.push(...result.responseMessages)
    requests = result.content.filter(part => part.type === 'tool-approval-request')
  }

  console.log(`\nAgent: ${result.text}`)
}

terminal.close()
```

Here is what each part does:

- **createGithubAgent** wraps the `issue-triage` tools in an AI SDK [`ToolLoopAgent`](https://ai-sdk.dev/docs/agents), with a system prompt written for triage. The agent runs up to 20 steps per call, so it doesn't need `stopWhen`.
- **context** sets the [working context](https://github-tools.com/context). The model can leave out `owner` and `repo`, and every tool call targets your repository.
- **messages** holds the whole conversation. Each `generate()` call gets it, and `result.responseMessages` adds the agent's tool calls, tool results and answer to it.
- **The inner loop** handles writes. When the model calls a write tool, `generate()` returns early with a `tool-approval-request` part instead of running it. The script asks you, adds your answers as a `tool` message, and calls `generate()` again. An approved write runs, and a denied one returns a denial the model reads. The loop repeats until the agent answers without asking.

<terminal-session>



</terminal-session>

## 6. Run it

Start the agent with your repository:

<code-group>

```bash [pnpm]
pnpm agent your-name/your-repo
```

```bash [npm]
npm run agent -- your-name/your-repo
```

```bash [yarn]
yarn agent your-name/your-repo
```

```bash [bun]
bun run agent your-name/your-repo
```

</code-group>

At the `You:` prompt, ask "Which open issues have no label? Suggest one for each." You should see an answer built from your issues, and no approval prompt: the agent only read.

Then ask it to act, for example "Add the bug label to issue 12". You should see a prompt like this, with your own issue number:

```text [Terminal]
Allow addLabels {"owner":"your-name","repo":"your-repo","issueNumber":12,"labels":["bug"]}? (y/n)
```

`owner` and `repo` come from the context, even though the model left them out. Type `y`, and the label appears on the issue on GitHub. Type `n`, and the write doesn't run: the agent reads the denial and answers without it. Type `exit` to quit.

## Change what it does

Each change below is one option on `createGithubAgent`:

- **Stop asking for labels.** `requireApproval: { addLabels: false }` lets labels run straight away, while comments, closes and every other write keep asking.
- **Let a model approve routine writes.** `requireApproval: 'auto'` asks a fast decision model whether each write is low-risk and was asked for. See [Approval](https://github-tools.com/approval#let-a-model-decide).
- **Give it house rules.** `additionalInstructions: 'Only use labels that already exist. Never close an issue.'` appends to the preset's system prompt.
- **Give it another job.** Swap `preset: 'issue-triage'` for `code-review` or `ci-ops`, or pass an array to combine them. Check the [token permissions](https://github-tools.com/auth#permissions-per-preset) for the new preset.
- **Stream the answer.** Replace `agent.generate()` with `agent.stream()` to print text as it arrives, or put the agent behind a chat UI as in [AI SDK](https://github-tools.com/integrations/ai-sdk#approval-in-a-chat-ui).

Before you deploy, replace the personal token with a GitHub App token from [Vercel Connect](https://github-tools.com/auth#vercel-connect), so the agent acts as the app with short-lived credentials.

## FAQ

<accordion>
<accordion-item label="Why doesn't ask.ts stop for approval?">

It never calls a write. The question only needs reads, and if the model did call a write, `generateText` would return the approval request without running it, and the script would end there.

</accordion-item>

<accordion-item label="Does the agent remember earlier conversations?">

No. The conversation lives in the `messages` array, in memory, so restarting the script starts a new one. Save the array and load it on start to keep a conversation across runs.

</accordion-item>

<accordion-item label="Can I approve every write in one answer?">

Yes. Change the inner loop to ask once for all `requests`, and push one `tool-approval-response` per request with the same answer.

</accordion-item>

<accordion-item label="What does the agent do when I type n?">

The write doesn't run. The model gets a denial as the tool's result, and the system prompt tells it not to retry the same call.

</accordion-item>

<accordion-item label="Can I use another model provider?">

Yes. Any AI SDK model that can call tools works. Install its provider package, such as `@ai-sdk/openai`, and pass its model object as `model`.

</accordion-item>
</accordion>


## Sitemap

See the full [sitemap](https://github-tools.com/sitemap.md) for all pages.
