Api Ai
AI and prompts
The choice to make first
| Route | Cost | Needs an AI key? | Scope |
|---|---|---|---|
GET /ai/prompts/suggestions/ |
free | no | read |
POST /ai/prompts/rewrite/ |
free | no | read |
POST /ai/suggestions/ |
one provider call | yes | write |
POST /ai/rewrite/ |
one provider call | yes | write |
The prompt routes return exactly what we would have sent to the model, with your real posts already interpolated. If the caller is itself an AI agent, it should run that prompt and skip the paid route entirely.
Grounding: the context field
Every route on this page takes an optional context string, up to 4,000
characters. Send it whenever you can.
Without it, the prompt contains a topic string and the account's own post history, and nothing else. Ask for posts about something neither one describes and the model does not stop. It invents the missing specifics and writes them as fact. This is not hypothetical: an account whose history was about an unrelated side project, asked for posts about a launch, produced "Just shipped the first version! Time to see how the App Store review goes" for a product with no app and no review.
context is where the real facts go, and the prompt tells the model to take
every specific from there or from the posts, and to invent nothing else:
{"topic": "the launch",
"context": "Shipped a public REST API (22 routes) and an MCP server on npm on 21 Aug. No mobile app."}
Longer input is clamped rather than rejected, and the response says so in
warnings with a parameter_clamped entry. Nothing here is stored; context
is an input, not part of the account's history.
GET /ai/prompts/suggestions/
Optional ?topic=shipping in public to steer it, ?count= for how many ideas
(1–20, default 5), and ?context= for ground truth.
{"status": "success", "data": {
"handle": "alice.bsky.social",
"post_count": 128,
"system": "...",
"prompt": "...",
"combined": "..."
}}
Use system and prompt separately, or combined if you have one input box.
The prompt asks for a single JSON object containing post_suggestions (a list
of strings) plus seven analysis keys.
POST /ai/prompts/suggestions/
The same route and the same response, with {"topic", "count", "context"} in
the body instead of the query string.
Prefer it whenever you are sending context. Ground truth tends to run to
paragraphs, and a query string is the wrong place for that: it lands in access
logs and proxy caches. It is the same reason the rewrite prompt has always been
POST-only.
POST /ai/prompts/rewrite/
{"text": "my rambling draft", "instructions": "punchier, under 300 characters",
"context": "optional ground truth"}
Returns the same system / prompt / combined trio.
Connectors: grounding a draft in a source instead of typed context
GET /connectors/ lists the sources you attached in Settings -- a GitHub
repo (commits and merged PRs from chosen repos) or a website/pasted notes --
each with its own stored instruction saying what to look for:
{"status": "success", "data": {"connectors": [
{"id": 3, "kind": "github", "name": "kunopilot repo",
"instruction": "Look for shipped features and bug fixes, ignore refactors.",
"cursor_at": "2026-09-01T09:00:00Z", "last_fetched_at": "2026-09-03T14:02:11Z",
"last_error": ""}
]}}
Pass up to 2 of their ids as connectors (a list, or a comma-separated
string) on any of the five AI routes on this page -- suggestions and rewrite,
prompt or paid -- alongside an optional window: today, 7d (default), or
since_last. The fetched material grounds the prompt exactly the way
context does -- it just comes from a source rather than from what you
typed:
{"topic": "this week in public", "connectors": [3], "window": "7d"}
Fetching a connector is free, but does require the account to have some
credit balance or its own AI key, and is capped to a limited number of runs
per day. A since_last window only advances that connector's cursor when
POST /ai/suggestions/ or POST /ai/rewrite/ actually runs a generation --
never on a prompt-only route, so previewing what a connector would contribute
can never cause a later real run to miss material.
Creating and editing connectors
Settings is not the only place to set these up -- the API can too.
POST /connectors/, write scope. Body {"kind": "github"|"custom", "name",
"instruction", "config", "installation"}. A custom connector's config is
{"url": "...", "text": "..."}, at least one required. A github
connector's config is {"repos": [{"full_name": "owner/repo", "branch":
"main"}, ...]} (at least one repo, branch optional), and top-level
installation must be an id from GET /connectors/github/installations/ that
belongs to the caller. name must be unique per account.
PATCH /connectors/{id}/, write scope, any of {"name", "instruction",
"config"} -- only the fields sent change. DELETE /connectors/{id}/, write
scope.
GET /connectors/github/installations/, free -- this account's connected
GitHub accounts, {"id", "account_login", "account_type", "status",
"last_error", "created_at"} each. One installation can back several
connectors, each watching a different repo selection.
GET /connectors/github/repos/?installation=<id>, free -- the repos that
installation can see, to build config.repos rather than guessing a name.
GET /connectors/github/branches/?installation=<id>&repo=<owner/repo>, free
-- branch names for one repo. A repo the installation cannot see 404s at
GitHub itself, since the call runs on that installation's own token.
Installing the GitHub App itself is the one step this API cannot finish.
It is a GitHub-hosted screen that only completes inside a browser where the
human is already logged into Kunopilot -- there is no session for an API-key
request to attach the install to. GET /connectors/github/connect-url/, free,
returns {"install_url", "instructions"}: send the user that link and have
them open it themselves, logged in. Once done, GET
/connectors/github/installations/ shows the new installation -- poll that
rather than asking the user to confirm it happened. 404s if the deployment has
no GitHub App configured.
POST /ai/suggestions/
Spends one call on your own key. Optional body
{"topic": "...", "count": 10, "context": "..."}.
{"status": "success", "data": {
"handle": "alice.bsky.social",
"posts_analyzed": 128,
"suggestions": ["...", "..."],
"insights": {"tone_style": {"...": "..."}}
}}
With no post history it returns an empty list and a message telling you to sync first, rather than an error.
GET /ai/suggestions/ and DELETE /ai/suggestions/{id}/
Listing stored suggestions is free and works with a read key. Deleting needs write scope, and only ever touches your own account's suggestions.
POST /ai/rewrite/
{"text": "my rambling draft", "instructions": "punchier"}
Returns {"text": "...", "original": "..."}.
This is POST, not GET, on purpose. The internal endpoint the web app uses puts draft text in the query string, which means post content lands in access logs. The public API does not repeat that.
When there is no AI key
Both paid routes return 403 no_ai_key with a prompt_url pointing at the free
equivalent, rather than dead-ending. Any agent worth using should follow it.
Running it in the background
Both POST /ai/suggestions/ and POST /ai/rewrite/ still answer synchronously
by default, exactly as above. A call can take tens of seconds -- it is a model
generating the response -- so a caller that would rather not hold a connection
open for that can opt in to a queued version with one header:
Prefer: respond-async
The response comes back immediately as 202:
{"status": "queued", "message": "...",
"job": {"id": 42, "kind": "api_suggestions", "label": "Suggestions for @alice.bsky.social",
"status": "queued", "active": true, "message": "", "result": {}}}
Poll GET /jobs/{id}/ until job.status is done or failed. job.result
is exactly the data object the synchronous call would have returned --
{"handle", "posts_analyzed", "suggestions", "insights", "credits_charged"}
for suggestions, {"text", "original", "credits_charged"} for a rewrite. A
failed job carries no result; job.message says why, in a sentence, and
nothing was charged for what did not complete.
Anything that can refuse the call cheaply is refused by the queuing request
itself, with the status it has always had: no key is 403 no_ai_key, an
empty balance is 402 insufficient_credits, a connector you do not own is
404. You never have to poll to find out you could not afford it. What can
still fail on the poll is the model call itself, such as a provider error.
{"status": "success", "data": {"job": {
"id": 42, "kind": "api_suggestions", "status": "done", "active": false,
"message": "Generated 5 suggestions.",
"result": {"handle": "alice.bsky.social", "posts_analyzed": 128,
"suggestions": ["...", "..."], "insights": {"...": "..."},
"credits_charged": 0}
}}}
Queuing a second run of the same kind while one is still in flight is refused
with 409 already_running rather than stacking a second charge. An account
with no post history to draft from is answered synchronously even with the
header set -- there is no model call to queue, so there is nothing to wait on.
A job is visible only to the key that queued it. Another key on the same
account, even one with write scope, gets the same 404 as a job that never
existed, so a key scoped to one group can never read what another group's key
asked for.
Idempotency-Key (see Drafts and scheduling)
works on both routes, with or without the header: retry the same POST with
the same key after a timeout and you get the first response back. Queued, that
is the same job rather than a second one; synchronous, it is the same result
with no second paid call.