# Semagram Agent API

Machine-to-machine API for Telegram channels, groups, and bots. Two endpoints: **search** finds entities you cannot name, **fetch** returns the stored messages and profile details of entities you can. Payments are handled via the [x402 protocol](https://www.x402.org/) — no API keys or subscriptions required.

On both, the parameters go in the query string, not in a body. The price is
quoted from the URL — that is all the payment layer sees — so a body would be
priced for one request and answer another.

## Search

```
POST /api/agent/search?q=<query>&limit=<n>&lang=<code>&kind=<type>&min_users=<n>&max_users=<n>
```

| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `q`       | Yes      | —       | Search query (semantic) |
| `limit`   | No       | 100     | Max number of results (1-1000). A larger value is clamped to 1000, and priced at the clamped figure. |
| `lang`    | No       | —       | Language filter (ISO 639-1 code, e.g. "en", "ru") |
| `kind`    | No       | —       | Entity type filter ("channel", "group", or "bot") |
| `min_users` | No     | —       | Minimum subscriber/member count |
| `max_users` | No     | —       | Maximum subscriber/member count |

## Fetch

```
POST /api/agent/fetch?usernames=<name1,name2,...>
```

| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `usernames` | Yes    | —       | Telegram usernames, comma-separated. Between 1 and 100; more is refused with 400 and charged nothing. `@name` and `t.me/name` are accepted and normalised. |

One entry comes back per distinct name, in the order asked, so an agent charged
per name can account for every one of them. A name the catalogue does not hold —
a channel that does not exist, a personal account, a name nobody has crawled —
comes back as `{"type": "absent"}` and **is charged for**: the lookup is the
product, and a miss costs the same to answer as a hit.

Messages exist for channels. A group and a bot come back with their profile
details and **no `messages` field at all**, because Telegram publishes no
readable history for either — an empty array would say "none yet", which is a
claim about our crawl rather than about Telegram. A channel we hold no posts for
*does* answer with an empty array: that one really is "none yet".

## Authentication

This endpoint uses the **x402 payment protocol** (HTTP 402 Payment Required).

1. Send a request without payment headers.
2. The server responds with `402 Payment Required` and a `Payment-Required` header: base64 of a JSON document naming the recipient address, token, amount and networks accepted.
3. Create a payment authorization matching one of them and resend the request with a `Payment-Signature` header. The authorization is valid for 5 minutes.
4. On a successful response (2xx), the payment is settled on-chain. On errors (4xx/5xx), no payment is charged.

Use an x402-compatible HTTP client (e.g., [x402-fetch](https://www.npmjs.com/package/x402-fetch), [x402-reqwest](https://crates.io/crates/x402-reqwest)) to handle the payment flow automatically.

## Pricing

**Search** scales with the requested `limit`:

| Limit     | Price (USDC) |
|-----------|-------------|
| 1-100     | 0.01        |
| 101-200   | 0.02        |
| 201-300   | 0.03        |
| 301-400   | 0.04        |
| 401-500   | 0.05        |
| 501-600   | 0.06        |
| 601-700   | 0.07        |
| 701-800   | 0.08        |
| 801-900   | 0.09        |
| 901-1000  | 0.10        |

Formula: `ceil(limit / 100) * 0.01 USDC`

**Fetch** is linear in the number of names:

| Usernames | Price (USDC) |
|-----------|-------------|
| 1         | 0.01        |
| 2         | 0.02        |
| 10        | 0.10        |
| 100       | 1.00        |

Formula: `names * 0.01 USDC`. Duplicate names are collapsed before pricing, so
asking for the same channel twice is charged once.

## Search response

**Content-Type:** `application/json`

```json
[
  {
    "uuid": "550e8400-e29b-41d4-a716-446655440000",
    "kind": "channel",
    "username": "example_channel",
    "name": "Example Channel",
    "bio": null,
    "description": "An example Telegram channel",
    "avatar_url": "https://example.com/avatar.jpg",
    "user_count": 15000
  }
]
```

| Field        | Type     | Description |
|-------------|----------|-------------|
| `uuid`      | string   | Unique identifier |
| `kind`      | string?  | `"channel"`, `"group"`, `"bot"`, or `null` |
| `username`  | string   | Telegram username |
| `name`      | string?  | Display name |
| `bio`       | string?  | Short bio |
| `description` | string? | Full description |
| `avatar_url` | string? | Avatar image URL |
| `user_count` | number? | Subscriber/member count |

## Fetch response

**Content-Type:** `application/json`

```json
[
  {
    "type": "channel",
    "username": "example_channel",
    "uuid": "550e8400-e29b-41d4-a716-446655440000",
    "name": "Example Channel",
    "bio": "Short channel bio",
    "avatar_url": "https://example.com/avatar.jpg",
    "user_count": 15000,
    "profile_scraped_at": "2026-07-31T02:14:00Z",
    "messages_scraped_at": "2026-07-31T02:15:00Z",
    "messages": [
      {
        "id": 412,
        "kind": "photo",
        "text": "An example post",
        "posted_at": "2026-07-30T11:04:00Z",
        "forwarded_from": "telegram",
        "link": "https://t.me/example_channel/412",
        "view_count": 91024,
        "reaction_count": 318,
        "image_count": 1
      }
    ]
  },
  {
    "type": "absent",
    "username": "no_such_name"
  }
]
```

| Field        | Type     | Description |
|-------------|----------|-------------|
| `type`      | string   | `"channel"`, `"group"`, `"bot"`, or `"absent"` when the catalogue holds no such entity |
| `username`  | string   | The name asked for, lowercased |
| `uuid`      | string?  | The same identifier `/api/agent/search` returns for this entity |
| `canonical_username` | string? | Telegram's display spelling, when it differs from `username` |
| `name`      | string?  | Display name |
| `bio`       | string?  | Short bio |
| `description` | string? | Full description. Bots only |
| `lang`      | string?  | Detected language (ISO 639-1) |
| `avatar_url` | string? | Avatar image URL |
| `user_count` | number? | Subscribers for a channel, members for a group. Never present for a bot |
| `mau`       | number?  | Monthly active users. Bots only |
| `commands`  | object?  | A bot's commands, name to description |
| `profile_scraped_at` | string? | When the profile was last read off Telegram successfully. Absent when that is not known |
| `messages_scraped_at` | string? | When the history was last read off Telegram successfully. Absent when that is not known |
| `messages`  | array?   | Stored posts, newest first. Present only for a channel, and then always an array — empty when we hold none. Absent for a group, a bot or an absent name |

### Messages

| Field | Type | Description |
|-------|------|-------------|
| `id`   | number | Telegram's post number |
| `kind` | string | What the post is. Always present — see below |
| `text` | string? | The post's text, or the caption of a media post. Absent when the post carried neither |
| `posted_at` | string | When it was posted |
| `forwarded_from` | string \| true? | Where the post came from — see below |
| `link` | string | `https://t.me/<username>/<id>` |
| `view_count` | number? | Views |
| `reaction_count` | number? | Reactions |
| `image_count` | number? | Images in the post |

**This API returns text, not media.** What is stored is a post's text and, for a
media post, its caption — never the file. So a photo posted with no caption is a
message with no `text` at all, and `kind` is the only thing that tells you it was
a photo rather than an empty message. It is always present, and it is one of:

`text`, `photo`, `video`, `round_video`, `voice`, `sticker`, `document`,
`media_group`, `poll`, `location`, `contact`, `invoice`, `service`,
`unsupported`, `unknown`

`forwarded_from` is one field with three states, because there are three and two
of them mean "this was forwarded". It is the origin's **username** when the
origin is public, the literal **`true`** when the post is a forward whose origin
chose to stay hidden, and **absent** when the post is original. Test for the key
to know whether a post is original; test whether the value is a string to follow
the trail.

`media_group` is an album; `image_count` says how many pictures it carried.
`service` is a system post such as a pinned message or a title change.
`unknown` is a post whose markup named no media and carried no text.

## Errors

| Status | Endpoint | Meaning |
|--------|----------|---------|
| 400    | search   | Empty or missing `q` parameter, or a `kind` that is not `channel`, `group` or `bot` |
| 400    | fetch    | No `usernames` given, or more than 100 of them |
| 402    | both     | Payment required (see Authentication) |
| 502    | both     | Upstream service unavailable |

Nothing is charged on a 4xx or a 5xx.

## Example

Using `curl` (manual flow):

```bash
# 1. Initial request returns 402 with payment requirements
curl -i -X POST 'https://tg-catalog.quoi.dev/api/agent/search?q=crypto+news'
curl -i -X POST 'https://tg-catalog.quoi.dev/api/agent/fetch?usernames=durov,telegram'

# 2. Use an x402 client library to handle payment automatically
```

Using `x402-fetch` (JavaScript):

```javascript
import { wrapFetch } from "x402-fetch";

const fetch402 = wrapFetch(fetch, wallet);

const found = await fetch402("https://tg-catalog.quoi.dev/api/agent/search?q=crypto+news", { method: "POST" });
const results = await found.json();

// Then read what those channels have been posting.
const names = results.map((r) => r.username).slice(0, 10).join(",");
const fetched = await fetch402(`https://tg-catalog.quoi.dev/api/agent/fetch?usernames=${names}`, { method: "POST" });
const entities = await fetched.json();
```

## Links

- [x402 Protocol](https://www.x402.org/)
- [x402 Documentation](https://docs.cdp.coinbase.com/x402/welcome)
