Agent API
The same catalogue the site runs on, addressed by a program. There is no account, no key and no invoice: the request pays for itself, so an agent that has never contacted us before can call it on its first try.
Two endpoints. Search finds entities you cannot name; fetch returns the stored messages and profile details of entities you can. On both, parameters go in the query string, not in a body: the price is quoted by the payment layer, which sees the URL and nothing else, 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 | Meaning |
|---|---|---|---|
q | yes | — | The query. Matched by meaning as well as by name; @username returns that entity and similar ones. |
limit | no | 100 | Results wanted, 1 to 1000. Above 1000 is clamped, and priced at the clamped figure. |
lang | no | — | ISO 639-1 code, e.g. en, ru. |
kind | no | — | channel, group or bot. |
min_users | no | — | Lowest subscriber or member count. |
max_users | no | — | Highest subscriber or member count. |
Fetch
POST /api/agent/fetch?usernames=<name1,name2,…> | Parameter | Required | Default | Meaning |
|---|---|---|---|
usernames | yes | — | Telegram usernames, comma-separated, 1 to 100. @name and t.me/name are accepted. Above 100 is refused with 400 and charged nothing. |
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".
Paying per request
Payment is the x402 protocol — HTTP 402, no keys and no subscription.
- Send the request with no payment header.
-
The reply is
402 Payment Requiredwith aPayment-Requiredheader: base64 of a JSON document naming the recipient, the token, the amount and the networks accepted. -
Authorise a payment matching one of them and repeat the request with a
Payment-Signatureheader. The authorisation is good for five minutes. -
On
200the payment settles on chain. On any error nothing is charged.
An x402 client library does all four steps for you: x402-fetch for JavaScript, x402-reqwest for Rust.
Search is a tier per hundred results asked for, rounded up, never fewer than one: ceil(limit / 100) × base. Fetch is linear in the names: names × base, with duplicates collapsed before pricing, so asking for the same
channel twice is charged once. Both in USDC, on Base and on Solana. The base amount is
quoted in the 402 itself rather than printed here, so what an agent reads is
always what it will be charged. It is also in /.well-known/x402 and openapi.json,
both of which are free to read.
Search response
200, application/json, an array in ranked order — the same shape /api/search returns. Empty is [], never null. Fields
with nothing in them are omitted rather than sent as null.
[
{
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"kind": "channel",
"username": "example_channel",
"name": "Example Channel",
"description": "An example Telegram channel",
"avatar_url": "https://semagram.io/avatar/example_channel.jpg",
"user_count": 15000
}
] | Field | Type | Meaning |
|---|---|---|
uuid | string | Stable identifier. Survives a rename; username does not. |
kind | string? | channel, group or bot. |
username | string | Telegram username, without the @. |
name | string? | Display name. |
bio | string? | Short bio. |
description | string? | Full description. |
avatar_url | string? | Avatar, served by us rather than by Telegram. |
user_count | number? | Subscribers or members, as of the last crawl. |
Fetch response
200, application/json, one entry per name in the order asked. messages is there only for a channel, and is then an array — never null, and empty when we hold no posts for it.
[
{
"type": "channel",
"username": "example_channel",
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"name": "Example Channel",
"bio": "Short channel bio",
"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 | Meaning |
|---|---|---|
type | string | channel, group, bot, or absent when the catalogue holds no such entity. |
username | string | The name asked for, lowercased. |
uuid | string? | The identifier the search returns for the same entity. |
canonical_username | string? | Telegram's own spelling, when it differs. |
bio, name, lang, avatar_url | string? | As in a search result. |
description | string? | Full description. Bots only. |
user_count | number? | Subscribers for a channel, members for a group. Never present for a bot. |
mau | number? | Monthly active users. Bots only — it is the audience a bot has instead of subscribers. |
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. Only a channel has this field at all. |
A message carries id, kind, posted_at, link, and optionally text, forwarded_from, view_count, reaction_count and image_count.
forwarded_from is one field with three states, because there are three and two
of them mean "this was forwarded": 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.
This returns text, not media. What is stored is a post's text and, for a
media post, its caption — never the file. A photo posted with no caption is therefore a
message with no text at all, and kind is the only thing that says
it was a photo rather than an empty message. It is always present, and one of: text, photo, video, round_video, voice, sticker, document, media_group, poll, location, contact, invoice, service, unsupported, unknown.
Errors
| Status | Meaning |
|---|---|
400 | Search: q is missing or empty, or kind is not one of the three. Fetch: no usernames, or more than 100. |
402 | No payment, or one that did not settle. Read the Payment-Required header. |
502 | The request could not be answered. Retriable; nothing is charged. |
Example
import { wrapFetch } from "x402-fetch";
const fetch402 = wrapFetch(fetch, wallet);
const found = await fetch402(
"https://semagram.io/api/agent/search?q=crypto+news&limit=200",
{ method: "POST" },
);
const results = await found.json();
// Then read what those channels have been posting.
const names = results.slice(0, 10).map((r) => r.username).join(",");
const fetched = await fetch402(
`https://semagram.io/api/agent/fetch?usernames=${names}`,
{ method: "POST" },
);
const entities = await fetched.json(); Model Context Protocol
/mcp serves the same catalogue as two tools, search and fetch, over streamable HTTP. They take the arguments above as a structured
object rather than a query string — fetch takes its usernames as an array — and
return the same rows.
It is behind OAuth 2.0, so the first connection sends you through a browser once and the client keeps the token afterwards. Our authorization server registers clients by hand rather than on demand, so the client id has to be given along with the address — without it a client that expects to register itself will report that the server is incompatible:
claude mcp add --transport http --client-id semagram-mcp semagram https://mcp.semagram.io/mcp Any MCP client works the same way; only the flag's spelling changes. If yours offers no way to set a client id, it cannot connect yet — tell us which one it is.
Access is granted on request, and unlike the paid endpoint there is no self-service sign-up. Write to [email protected] with the client you will connect with and its client id, and you will be sent credentials.
Also
- agent-api.md — this page as one Markdown file, for a model to read.
- openapi.json — the OpenAPI description.
- /.well-known/x402 — the payment discovery document.
- x402 documentation — the protocol itself.