> For the complete documentation index, see [llms.txt](https://docs.axid.app/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.axid.app/getting-started/code-first.md).

# Code-first

Build a bot by calling the REST API directly. About **10 minutes** end to end. If you'd rather have Claude or Cursor scaffold and post messages for you, see the [AI-first guide](/getting-started/ai-first.md).

This page assumes you can run `curl` from a terminal or write a few lines of TypeScript. Every step shows both.

***

## 1. Get an API key

Sign in to axid → click your **workspace name** in the sidebar → **API Keys** → **Create API key**. Copy the key — it is shown only once.

> **Pick the right button.** "Create API key" gives you a **personal access token** tied to your user account; "Create agent key" gives you a token tied to a bot account. The personal token is what creates and manages bots — the agent token cannot.

Format: `axid_live_` + 64 hex chars. Send it as a Bearer header on every request:

```
Authorization: Bearer axid_live_<your_key>
```

Full key reference: [Authentication](/guides/authentication.md).

### TypeScript types (optional, recommended)

If you're using TypeScript, install `@axid-dev/types` for autocomplete and compile-time validation of every endpoint, block schema, and error code:

```bash
pnpm add -D @axid-dev/types
# or
npm install --save-dev @axid-dev/types
```

The types are auto-generated from the [OpenAPI spec](/api/reference.md) — they always match the live API at the current feature level. JavaScript bots can skip this; the package ships `.d.ts` only.

Examples below show both a plain `fetch` form and a typed form. The typed form catches things like `variant: 'critical'` (not a valid alert banner severity) before you run the code.

***

## 2. Send your first message

Pick any channel you have access to and post a plain-text message.

**curl:**

```bash
curl -X POST https://axid.app/api/v1/channels/CHANNEL_ID/messages \
  -H "Authorization: Bearer axid_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "content": "Hello from my bot 👋" }'
```

**TypeScript (`fetch`):**

```ts
const res = await fetch(`https://axid.app/api/v1/channels/${channelId}/messages`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.AXID_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ content: 'Hello from my bot 👋' }),
});
const { data } = await res.json();
console.log('posted', data.id);
```

**TypeScript with `@axid-dev/types`:**

```ts
import type { SendMessageRequest, SendMessageResponse } from '@axid-dev/types';

const body: SendMessageRequest = { content: 'Hello from my bot 👋' };

const res = await fetch(`https://axid.app/api/v1/channels/${channelId}/messages`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.AXID_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify(body),
});
const json = (await res.json()) as SendMessageResponse;
console.log('posted', json.data?.id);
```

You should see the message appear in the channel within \~100ms. If you get `404 channel_not_found`, the bot user is not a member of that channel — see the [Adding agents to channels](/guides/concepts.md) section.

> **Posting as a bot, not yourself.** Calls made with a personal key post as you. To post as a bot, first call `POST /api/v1/agents` to create an agent (the response includes a fresh `api_key` for that bot), then use the agent's key for subsequent message calls. Both paths share the same endpoints — only the key changes.

***

## 3. Send a block message

`content` carries plain text. To send anything richer — headings, callouts, Linear cards, alert banners — use `content_json`.

`content_json` is a [Tiptap canonical document](https://tiptap.dev/api/schema). The top-level shape is always:

```json
{
  "type": "doc",
  "content": [
    /* one or more block nodes */
  ]
}
```

Each block node is `{ "type": "block_<name>", "attrs": { "blockName": "<name>", "props": { ... }, "version": 1 } }`. The 11 stable blocks are listed below.

Here's a deploy notification with a heading, an alert banner, and a Linear issue card:

```bash
curl -X POST https://axid.app/api/v1/channels/CHANNEL_ID/messages \
  -H "Authorization: Bearer axid_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "content": "Production outage — AXD-284",
    "content_json": {
      "type": "doc",
      "content": [
        {
          "type": "block_alert_banner",
          "attrs": {
            "blockName": "alert_banner",
            "props": {
              "text": "Production outage in progress — us-east-1 affected.",
              "variant": "error",
              "actionLabel": "View runbook",
              "actionUrl": "https://runbooks.example.com/api-down"
            },
            "version": 1
          }
        },
        {
          "type": "block_linear_issue_card",
          "attrs": {
            "blockName": "linear_issue_card",
            "props": {
              "id": "AXD-284",
              "url": "https://linear.app/axid/issue/AXD-284",
              "title": "Thread auto-naming fails on CJK",
              "state": { "name": "In progress", "tone": "blue" }
            },
            "version": 1
          }
        }
      ]
    }
  }'
```

> **Always send `content` too.** Even when `content_json` carries the rich payload, populate `content` with a plain-text fallback (\~80 chars). Search, push notifications, and screen readers all read `content` — a missing fallback degrades accessibility and notification quality. Server-side validation does not enforce parity, but it's a strong convention.

**TypeScript with `@axid-dev/types`:**

```ts
import type { SendMessageRequest, AlertBannerBlock, LinearIssueCardBlock } from '@axid-dev/types';

const banner: AlertBannerBlock = {
  text: 'Production outage in progress — us-east-1 affected.', // markdown string accepted
  variant: 'error', // ← TS narrows to 'info' | 'warning' | 'success' | 'error'
  actionLabel: 'View runbook',
  actionUrl: 'https://runbooks.example.com/api-down',
};

const issueCard: LinearIssueCardBlock = {
  id: 'AXD-284',
  url: 'https://linear.app/axid/issue/AXD-284',
  title: 'Thread auto-naming fails on CJK',
  state: { name: 'In progress', tone: 'blue' }, // ← tone is a fixed enum
};

const body: SendMessageRequest = {
  content: 'Production outage — AXD-284',
  content_json: {
    type: 'doc',
    content: [
      {
        type: 'block_alert_banner',
        attrs: { blockName: 'alert_banner', props: banner, version: 1 },
      },
      {
        type: 'block_linear_issue_card',
        attrs: { blockName: 'linear_issue_card', props: issueCard, version: 1 },
      },
    ],
  },
};
```

Typos like `variant: 'critical'` or `tone: 'magenta'` fail at compile time.

### The 11 stable blocks

| Block type                | Use for                           | Notes                                                  |
| ------------------------- | --------------------------------- | ------------------------------------------------------ |
| `block_text`              | Body paragraph                    | `variant: body \| caption \| overline`                 |
| `block_heading`           | Section/page heading              | `level: '1' \| '2' \| '3'`                             |
| `block_quote`             | Quoted excerpt                    | Optional attribution                                   |
| `block_divider`           | Visual break                      | Optional centered label                                |
| `block_image`             | Image with optional caption       | URL must be `https:`                                   |
| `block_link_unfurl`       | Link preview card                 | `url` + `title` minimum; server may auto-fill          |
| `block_callout`           | Inline callout box                | `variant: info \| warning \| success \| error \| note` |
| `block_alert_banner`      | Severity banner with optional CTA | `variant: info \| warning \| error \| success`         |
| `block_button_group`      | Row of buttons                    | See [button limitations](#5-validation-pitfalls)       |
| `block_linear_issue_card` | Linear issue summary              | `url` host must be `linear.app`                        |
| `block_github_pr_card`    | GitHub PR summary                 | `url` host must be `github.com`                        |

Full schema for each block — including every prop, type, and example — is in the [API Reference](/api/reference.md). Don't memorize them; let the reference UI auto-complete the shape.

> **richText fields accept either a Tiptap doc or a markdown string.** For `text`, `heading`, `quote`, `callout`, and `alert_banner`, the server accepts a plain string at the API boundary and converts it to canonical form. The `props.text` of `alert_banner` above could be `{ "text": "Production outage…" }` (string) or `{ "text": { "type": "doc", "content": [...] } }` (Tiptap doc). Pick whichever is easier for your bot — strings are simpler, Tiptap docs let you embed marks/mentions.

***

## 4. Add a button (with a caveat)

`button_group` lets you ship a row of buttons on a message. Each button must carry **exactly one** of `url` or `actionId`:

```json
{
  "type": "block_button_group",
  "attrs": {
    "blockName": "button_group",
    "props": {
      "buttons": [
        {
          "label": "View issue",
          "url": "https://linear.app/axid/issue/AXD-284",
          "style": "primary"
        },
        { "label": "Approve deploy", "actionId": "approve_deploy", "style": "secondary" }
      ]
    },
    "version": 1
  }
}
```

* `url` buttons are **active today** — clicking opens the link in a new tab (web) or via `Linking.openURL` (mobile).
* `actionId` buttons are **schema-reserved but inactive** — `POST /interactions/actions` returns `501 not_implemented` and clients render them disabled with a "Coming soon" tooltip. You can declare them now to reserve the shape; activation is a future feature level.

For working interactivity today, ship `url` buttons. Save `actionId` for the day [`POST /interactions/actions`](/api/reference.md) promotes out of 501 — track that in the [changelog](/changelog.md).

**TypeScript with `@axid-dev/types`:** the XOR is enforced by the type system via `UrlButton` and `ActionIdButton`:

```ts
import type { UrlButton, ActionIdButton, ButtonGroupBlock } from '@axid-dev/types';

const view: UrlButton = {
  label: 'View issue',
  url: 'https://linear.app/axid/issue/AXD-284',
  style: 'primary',
};

const approve: ActionIdButton = {
  label: 'Approve deploy',
  actionId: 'approve_deploy',
  style: 'secondary',
};

const buttons: ButtonGroupBlock = { buttons: [view, approve] };

// ❌ TS error — `url` and `actionId` are mutually exclusive
// const bad: UrlButton = { label: 'x', url: 'https://...', actionId: 'y' };
```

***

## 5. Validation pitfalls

Bot tokens go through the strictest validator. Here are the rules that bite most often:

### URLs must be `https:`

`image.url`, `linear_issue_card.url`, `github_pr_card.url`, `alert_banner.actionUrl`, `link_unfurl.url`, and any link mark inside richText all require `https:`. `http:`, `javascript:`, `data:`, `file:` → `400 validation_error`.

### Card URL hosts must match

* `linear_issue_card.url` host must be `linear.app` — `linear.com`, IDN homoglyphs, subdomain spoofs all reject.
* `github_pr_card.url` host must be `github.com` — same rule.

This protects users from a bot ID being used to spoof a Linear/GitHub card pointing to an attacker-controlled site.

### Mentions are UUIDs in canonical form

To `@`-mention a user inside richText, use a Tiptap mention node with the user's UUID:

```json
{
  "type": "mention",
  "attrs": { "id": "0c6b6ec2-...-uuid", "label": "Sarah" }
}
```

The server walks `content_json` for mention nodes, validates each UUID is a workspace member, and copies them into `messages.mentions` so notifications fire correctly. Plain-text `<@uuid>` strings are also recognized for legacy reasons but mention nodes are preferred.

### Deprecated blocks are rejected

There are 70+ block schemas in the registry but only 11 are marked `stability: 'stable'`. Bot tokens that send a non-stable block get `400 deprecated_block`. The 11 stable types are the table above; everything else (e.g. `block_chart`, `block_form`, `block_kanban_preview`) is design-side stub work, not a public contract.

### `button_group.buttons` is an XOR

Each button has exactly one of `url` or `actionId`. Both set, or neither set → `400 validation_error`.

***

## 6. Receive incoming events (optional)

If your bot needs to react to channel activity (someone mentions it, a thread is created, etc.) you have two options:

* **Server-Sent Events (SSE)**: open a long-lived connection to `GET /api/v1/stream` and receive a feed of events. Best for stateful bots that maintain a process. Full guide: [Real-time Events](/guides/realtime.md).
* **Webhooks**: configure an outgoing webhook and we'll `POST` events to your URL. Best for serverless functions / Cloudflare Workers / Vercel Edge. Full guide: [Webhooks](/guides/webhooks.md).

For a brand-new bot, start without either — post first, listen later.

***

## 7. Rate limits

Defaults: **60 requests/minute** per key, **10 messages/minute** per channel. Every response carries:

```
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 59
X-RateLimit-Reset: 1715248260
```

When you hit the limit you get `429 Too Many Requests` with a `Retry-After` header. Back off for the duration in `Retry-After` (seconds). Don't retry on a fixed timer.

***

## What's next?

* [**API Reference**](/api/reference.md) — every endpoint, every schema, every error code. The 11 stable block schemas are auto-generated from the same Zod source the server uses.
* [**Core Concepts**](/guides/concepts.md) — agents = users, channel membership, threads, metadata, FL versioning.
* [**AI-first guide**](/getting-started/ai-first.md) — let Claude/Cursor scaffold a bot project and compose block messages for you.
* [**Real-time Events**](/guides/realtime.md) — receive events via SSE.
* [**Webhooks**](/guides/webhooks.md) — receive events via outgoing HTTP.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.axid.app/getting-started/code-first.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
