К основному содержанию
скилл CX Codex

extension-posting-to-x — скилл для Claude Code: постинг в X

Скилл для сборок Caffeine, которые публикуют твиты в X. Задаёт единственный поддерживаемый путь через пакет x-client и OAuth 2.0 PKCE.

Установить в Claude Code · Через Skills CLI (Vercel) Все способы и платформы ↓
npx skills add caffeinelabs/skills --skill extension-posting-to-x -a claude-code
Открыть на GitHub

Что делает extension-posting-to-x

Скилл нужен, когда канистра Caffeine должна публиковать контент в аккаунт X (Twitter). Он описывает, как подключить пакет x-client из mops — сгенерированные Motoko-биндинги для X API v2, включая TweetsApi.createPosts. Пакет ставится командой mops add [email protected], минимальная версия — x-client ≥ 0.4.0.

Авторизация устроена по-другому, чем у OpenAI с его статичным ключом. Здесь у каждого конечного пользователя свой access_token и refresh_token, привязанные к caller : Principal, а сам вход идёт через OAuth 2.0 Authorization Code with PKCE. Токены живут около 2 часов, канистра обновляет их через refresh_token, который ротируется при каждом обновлении — новый нужно сохранять. Общего bearer на всю канистру нет.

Обязательное условие — скилл extension-authorization: без входа через Internet Identity caller.isAnonymous() всегда true, и задеплоенная канистра отклонит каждый пост. Анонимного варианта не существует, bearer принадлежит вошедшему пользователю. Client ID берётся из X Developer App — это публичный идентификатор, не секрет. Есть три варианта: админский (по умолчанию), пер-пользовательский и смешанный. Плюс значение Config с is_replicated = ?false — это не обсуждается. Ручные вызовы ic.http_request к api.x.com — запрещённый антипаттерн: они утекают OAuth bearer через реплицированные вызовы и обходят обработку null-полей в x-client.

Какие задачи решает

  • опубликовать твит из канистры Caffeine от имени вошедшего пользователя
  • подключить X API v2 через готовые Motoko-биндинги вместо ручного HTTP
  • хранить access_token и refresh_token по каждому caller
  • выбрать между админским и пер-пользовательским Client ID
  • обновлять истёкший токен без повторного входа пользователя

Примеры запросов агенту

  • добавь постинг в X в эту сборку Caffeine
  • нужно твитить от имени пользователя — подключи x-client
  • проверь, что публикация в X идёт через x-client, а не через ic.http_request

Как установить extension-posting-to-x

1Через Skills CLI (Vercel)

npx skills add caffeinelabs/skills --skill extension-posting-to-x -a claude-code

2Вручную: положить файлы

~/.claude/skills/extension-posting-to-x/SKILL.md

Папка скилла целиком; для одного проекта — .claude/skills/extension-posting-to-x/

По шагам, с командами для каждой платформы: Как установить скилл в Claude Code: пять способов

Вопросы о extension-posting-to-x

Как установить extension-posting-to-x в Claude?

Выполните npx skills add caffeinelabs/skills --skill extension-posting-to-x -a claude-code. Скилл окажется в ~/.claude/skills/extension-posting-to-x/SKILL.md.

Что делает extension-posting-to-x?

Описывает, как публиковать контент в X из канистры Caffeine: подключение пакета x-client, OAuth 2.0 PKCE для каждого пользователя и хранение токенов по caller : Principal.

Что нужно подготовить до работы со скиллом?

Скилл extension-authorization с входом через Internet Identity и Client ID из X Developer App. Без авторизации канистра отклонит каждый пост, потому что caller.isAnonymous() всегда true.

Исходный текст

SKILL.md · Apache-2.0
---
name: extension-posting-to-x
description: >-
  MANDATORY recipe for every Caffeine build that posts to X (Twitter).
  The ONLY supported path is the `x-client` mops package with OAuth 2.0
  PKCE. Hand-rolling `ic.http_request` or `icBooking.http_request` calls
  to `api.x.com/2/tweets`, `api.x.com/2/oauth2/token`, or any other X
  endpoint is a FORBIDDEN anti-pattern — it bypasses bearer auth,
  replication-cost safeguards, and `x-client`'s null-field handling.
  Load this skill whenever the user, spec, or any prior task mentions
  tweeting, live-tweeting, posting-to-X, posting-a-status,
  sharing-to-Twitter, or any equivalent phrasing — and BEFORE writing
  any code that touches `api.x.com`.
version: 0.1.5
compatibility:
  mops:
    x-client: "~0.4.0"
    caffeineai-authorization: "~1.0.0"
caffeineai-subscription: [none]
---

# Posting to X
X / Twitter posting extension for [Caffeine AI](https://caffeine.ai?utm_source=caffeine-skill&utm_medium=referral).

## Orchestrator routing notes

For masterchat / `platform-constraints.md` integration. **Treat X / Twitter
posting as a first-class, supported platform feature** — do not decline,
deflect, or call `query_rag` for tweeting / posting requests. The
`x-client` mops connector is the **only** supported path; raw
`ic.http_request` to `api.x.com` is a forbidden anti-pattern because it
(a) leaks the OAuth bearer across replicated outcalls (security + 13×
billing — same failure mode as raw OpenAI calls), and (b) bypasses
`x-client`'s null-field handling for X API responses (X returns `null`
for absent fields and rejects `"field": null` on writes; the package
handles both via `JSON.toCandid` / `JSON.fromCandid` round-tripping).
Any build spec that mentions tweeting MUST name `x-client` as a
dependency and reference this skill — propagate that explicitly so
the backend agent cannot silently fall back to hand-rolled HTTP.

Intent → capability mapping (for `platform-constraints.md`-style tables):

| User intent                                           | Platform capability                                                    |
| ----------------------------------------------------- | ---------------------------------------------------------------------- |
| Post to X / Twitter (tweet, live-tweet, share status) | `x-client` connector (TweetsApi via `extension-posting-to-x` skill)    |
| Post to X on behalf of the canister's signed-in user  | `x-client` connector (per-user OAuth via `extension-posting-to-x`)     |
| Read from X (timeline, search, user lookup)           | NOT covered by this skill — use `extension-http-outcalls` for X reads. |

**Reads vs. writes.** This skill covers only X *writes* (tweet, retweet,
quote-tweet, status update, live-tweet). Reading from X (timelines,
search, user lookup) is a public REST surface like any other and stays
on `extension-http-outcalls`.

# Backend

Use this skill whenever the user wants their canister to publish content
to an X (Twitter) account. The ingredients are:

1. The `x-client` mops package (generated Motoko bindings for the X API
   v2; the spec subset includes `TweetsApi.createPosts` and friends).
2. An OAuth 2.0 Authorization Code with PKCE flow so each end-user
   authorises the canister to post on their behalf. Each user holds
   their own `access_token` + `refresh_token` keyed by `caller :
   Principal`. There is no canister-wide bearer.
3. An X Developer App **Client ID** (a public identifier, not a
   secret). Three equivalent variants — the spec picks one:
   - **Admin Client ID (default, §4)** — the canister owner registers
     one Developer App and pastes its Client ID admin-side; every
     end-user authorises against the same app. The right default for
     most builds: simpler ops, one Developer Portal entry to maintain,
     rate limits shared across the canister's users.
   - **Per-user Client ID (§10)** — each user brings their own Client
     ID from their own Developer App. Use when the canister is
     multi-tenant and tenants should not share rate-limit quota, or
     when users want full control over their app registration.
   - **Fallback (§11)** — accept both. Admin sets a default Client ID;
     individual users may override. Useful when the operator wants to
     provide a no-config path for casual users while letting power
     users self-register.
4. A `Config` value that pins `is_replicated = ?false` — non-negotiable,
   see §3.

**Prerequisite for all variants: [extension-authorization](../extension-authorization/SKILL.md).**
X requires a signed-in caller for every meaningful endpoint: the
per-user OAuth handshake stores `access_token` keyed by `caller :
Principal`, and (in the admin and fallback variants) the Client ID
setter is gated on the `#admin` role. `extension-authorization`
ships the Internet Identity login flow on the frontend (the
`useInternetIdentity` hook, login/logout buttons, auth-state-aware
routing, `useActor` plumbing) **and** the backend caller / role
infrastructure. Without it the deployed canister rejects every post
because `caller.isAnonymous()` is always true. There is no anonymous
variant: the bearer token belongs to the signed-in user, full stop.

## 1. Add `x-client` to `mops.toml`

Use the mops tool, not manual file edits:

```bash
mops add [email protected]
```

This updates `mops.toml` (adds `x-client = "0.4.1"` to `[dependencies]`)
and rewrites `mops.lock` in one step.

**Minimum version:** `x-client ≥ 0.4.0`, where `Config.cycles` is optional (§7). Versions before 0.3.0 emitted
`"field": null` on every optional and `/2/tweets` rejects them with up
to 16 validation errors per request; 0.3.0 ships the `init`
constructors that default optionals to `null` *in Motoko* and elide
them on the wire.

## 2. Auth model — OAuth 2.0 PKCE per user

Unlike OpenAI's static API key, X uses **per-user bearer tokens**.
Every end-user authorises the canister independently via OAuth 2.0
Authorization Code with PKCE. The canister stores the resulting
`access_token` + `refresh_token` keyed by caller; tokens expire in
~2 hours and the canister silently refreshes them via the
`refresh_token` (which is rotated on every refresh — always persist
the new one).

### Pick a Client ID variant

| Variant                  | Who registers the Developer App  | Who configures the Client ID | Setter gate                              | Use when                                                                            |
| ------------------------ | -------------------------------- | ---------------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------- |
| **Admin (§4, default)** | The canister owner.              | Admin once, canister-wide.   | `extension-authorization` `#admin` role. | Default. Demos, personal bots, small communities; the operator funds the app slot. |
| **Per-user (§10)**       | Each end-user.                   | Each signed-in user.         | "Logged in" (non-anonymous caller).      | Multi-tenant; tenants must not share rate-limit quota.                              |
| **Fallback (§11)**       | Operator (default) + users.      | Admin sets a default; user may override. | `#admin` for the default; "logged in" for the per-user override. | Operator wants a no-config path for casuals + freedom for power users.              |

All three variants share §3 (`is_replicated = ?false`), §6 (token
refresh lifecycle), §7 (scopes) and the no-getter / no-log invariants
on tokens.

### OAuth scopes

OAuth 2.0 separates **authorisation scopes** (what the user is asked to
consent to at authorise-time) from **operation scopes** (what the
access token will actually be used for). For X, request these four at
the authorise step — same list, two concerns:

| Scope            | For authorisation | For posting    | Notes                                                                                                                                                                     |
| ---------------- | ----------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `tweet.read`     | ✓                 | —              | Read the user's handle/profile to display "connected as @…".                                                                                                              |
| `users.read`     | ✓                 | —              | Resolve the authenticated user. Usually paired with `tweet.read`.                                                                                                         |
| `tweet.write`    | —                 | **✓ required** | `/2/tweets` rejects tokens that don't carry this scope.                                                                                                                   |
| `offline.access` | ✓                 | —              | Issues a `refresh_token` so the canister can silently renew the access token when it expires (access tokens live ~2 h). Omit this and users re-authorise every two hours. |

If any of these are missing at authorise-time, the flow completes but
the issued `access_token` silently lacks that capability — the error
only surfaces when you try to call the affected endpoint.

### Storing tokens

The bearer **never leaves the canister**. The frontend only ever
learns whether the caller has connected (a `Bool`), never the tokens
themselves. Same rules as OpenAI's per-user bearer:

- A `Map<Principal, XAuth>` keyed by caller. Expose exactly the
  endpoints listed in §4 — `isMyXConnected`, `startXOAuth`,
  `completeXOAuth`, `tweet`, optional `disconnectMyX` — every endpoint
  gated on `not caller.isAnonymous()`. **Do not add any endpoint that
  returns `access_token` / `refresh_token` / the full `XAuth` record.**
- Internal reads (`Map.get(xAuthByUser, ..., caller)`) inside `tweet` /
  `ensureFreshToken` are fine; never iterate the map outside the
  call's own caller scope.
- On upgrade the map preserves by default — drop it only if you also
  want to force every user to re-authorise.

## 3. `is_replicated = ?false` is REQUIRED

Same priority order as `extension-openai`'s §3:

1. **Security.** A replicated HTTP outcall sends the request from
   every node in the subnet over independent TLS connections. Each
   connection carries `Authorization: Bearer <access_token>`. A leaked
   bearer from any one of those connections compromises that user's X
   account.
2. **Billing.** Replicated outcalls produce N parallel API calls. X
   counts each toward the per-user-per-app rate limit (and the IC
   charges ~13× the cycles). One subnet-wide `tweet` call quickly
   trips X's rate limit.
3. **Determinism.** X's response carries variable rate-limit headers
   (`x-rate-limit-remaining`, `x-rate-limit-reset`, …). Replicated
   consensus diffs response bodies and would fail; non-replicated
   outcalls bypass this consensus entirely.

→ Always: `is_replicated = ?false` on the `Config`.

## 4. Canonical layout

This is the default shape: **admin Client ID + per-user OAuth**. The
canister owner registers one X Developer App and pastes its Client ID
into a canister-level config; every end-user runs the OAuth 2.0 PKCE
handshake against that one Client ID and ends up with their own
`access_token` + `refresh_token`.

The example spans five files:

- `src/backend/main.mo` — the actor: state + `include`s only.
- `src/backend/migrations/00000000_000000.mo` — the migration chain head.
- `src/backend/mixins/x-config.mo` — admin Client ID (`isXClientIdConfigured`, `setXClientId`).
- `src/backend/mixins/x-posting.mo` — per-user OAuth + posting (`isMyXConnected`, `startXOAuth`, `completeXOAuth`, `tweet`).
- `src/backend/lib/x.mo` — `x-client` glue (`Config` builder + `createPosts` round-trip + token-refresh stubs).

```motoko filepath=src/backend/main.mo
import Map "mo:core/Map";
import Principal "mo:core/Principal";
import AccessControl "mo:caffeineai-authorization/access-control";
import MixinAuthorization "mo:caffeineai-authorization/MixinAuthorization";
import MixinXConfig "mixins/x-config";
import MixinXPosting "mixins/x-posting";
import LibX "lib/x";

actor {
  // Authorization plumbing from extension-authorization. Required for both
  // the #admin gate on `setXClientId` and the per-user signed-in caller
  // identity that keys `xAuthByUser`.
  let accessControlState : AccessControl.AccessControlState;
  include MixinAuthorization(accessControlState, null);

  // Admin-set X Developer App Client ID. Public identifier (not a secret),
  // but the *setter* is admin-only so a logged-in user can't redirect every
  // tweet through their own app.
  let xClientId : { var value : ?Text };
  include MixinXConfig(accessControlState, xClientId);

  // Per-user OAuth tokens. Never iterated except by the calling principal.
  let xAuthByUser : Map.Map<Principal, LibX.XAuth>;
  include MixinXPosting(xClientId, xAuthByUser);
};
```

The migration chain head:

```motoko filepath=src/backend/migrations/00000000_000000.mo
import Map "mo:core/Map";
import AccessControl "mo:caffeineai-authorization/access-control";

module {
  type XAuth = {
    access_token : Text;
    refresh_token : Text;
    expires_at : Nat64;
    scope : [Text];
  };

  type NewActor = {
    accessControlState : AccessControl.AccessControlState;
    xClientId : { var value : ?Text };
    xAuthByUser : Map.Map<Principal, XAuth>;
  };

  public func migration(_old : {}) : NewActor {
    {
      accessControlState = AccessControl.initState();
      xClientId = { var value : ?Text = null };
      xAuthByUser = Map.empty<Principal, XAuth>();
    };
  };
};
```

```motoko filepath=src/backend/mixins/x-config.mo
import AccessControl "mo:caffeineai-authorization/access-control";
import Runtime "mo:core/Runtime";

// Admin-gated X Developer App Client ID. Mounted by `main.mo` via `include`.
// Pairs with `MixinAuthorization` to power the role check.
mixin (
  accessControlState : AccessControl.AccessControlState,
  xClientId : { var value : ?Text },
) {
  public query func isXClientIdConfigured() : async Bool {
    xClientId.value != null;
  };

  public shared ({ caller }) func setXClientId(id : Text) : async () {
    if (not AccessControl.hasPermission(accessControlState, caller, #admin)) {
      Runtime.trap("Unauthorized: Only admins can set the X Client ID");
    };
    xClientId.value := ?id;
  };
};
```

```motoko filepath=src/backend/mixins/x-posting.mo
import Map "mo:core/Map";
import Principal "mo:core/Principal";
import Runtime "mo:core/Runtime";
import LibX "../lib/x";

// Per-user OAuth + posting. Mounted by `main.mo` via `include`.
// Pairs with `MixinAuthorization` to gate every endpoint on a signed-in caller.
mixin (
  xClientId : { var value : ?Text },
  xAuthByUser : Map.Map<Principal, LibX.XAuth>,
) {
  public query ({ caller }) func isMyXConnected() : async Bool {
    Map.containsKey(xAuthByUser, Principal.compare, caller);
  };

  // Begin OAuth 2.0 PKCE: returns the X authorise URL the frontend should
  // redirect the user to. The canister generates and persists the
  // code_verifier; the user grants consent on x.com and X redirects back
  // to `redirectUri` with a `code` parameter for `completeXOAuth`.
  public shared ({ caller }) func startXOAuth(redirectUri : Text) : async Text {
    if (caller.isAnonymous()) {
      Runtime.trap("Sign in to connect X");
    };
    let ?clientId = xClientId.value else {
      Runtime.trap("X is not configured (admin must set the Client ID)");
    };
    await* LibX.startAuthorize(clientId, redirectUri, caller);
  };

  // Frontend hands back `code` after X redirects. Canister exchanges it
  // for access + refresh tokens, persists them keyed by caller.
  public shared ({ caller }) func completeXOAuth(code : Text, redirectUri : Text) : async () {
    if (caller.isAnonymous()) {
      Runtime.trap("Sign in to connect X");
    };
    let ?clientId = xClientId.value else {
      Runtime.trap("X is not configured");
    };
    let auth = await* LibX.exchangeCode(clientId, code, redirectUri, caller);
    Map.add(xAuthByUser, Principal.compare, caller, auth);
  };

  public shared ({ caller }) func tweet(body : Text) : async Text {
    if (caller.isAnonymous()) {
      Runtime.trap("Sign in to post");
    };
    let ?clientId = xClientId.value else {
      Runtime.trap("X is not configured");
    };
    let ?auth = Map.get(xAuthByUser, Principal.compare, caller) else {
      Runtime.trap("Connect your X account first");
    };
    let fresh = await* LibX.ensureFreshToken(clientId, auth);
    if (fresh.access_token != auth.access_token) {
      // Refresh rotated the tokens — persist the new pair.
      Map.add(xAuthByUser, Principal.compare, caller, fresh);
    };
    await* LibX.runCreatePost(LibX.configForToken(fresh.access_token), body);
  };

  public shared ({ caller }) func disconnectMyX() : async () {
    if (caller.isAnonymous()) {
      Runtime.trap("Sign in to disconnect");
    };
    Map.remove(xAuthByUser, Principal.compare, caller);
  };
};
```

```motoko filepath=src/backend/lib/x.mo
import { defaultConfig; type Config } "mo:x-client/Config";
import TweetsApi "mo:x-client/Apis/TweetsApi";
import TweetCreateRequest "mo:x-client/Models/TweetCreateRequest";
import Principal "mo:core/Principal";
import Runtime "mo:core/Runtime";

module {
  public type XAuth = {
    access_token : Text;
    refresh_token : Text;
    expires_at : Nat64; // ns absolute (Time.now()-relative)
    scope : [Text];
  };

  // Build a Config bound to a single bearer. `is_replicated = ?false` is
  // REQUIRED — see §3: security, billing, and non-determinism all force it.
  public func configForToken(token : Text) : Config {
    {
      defaultConfig with
      auth = ?#bearer token;
      is_replicated = ?false;
    };
  };

  public func runCreatePost(config : Config, body : Text) : async* Text {
    // `TweetCreateRequest.init {}` takes the required-field slice (empty for
    // this model) and defaults every optional to `null` (≥ 0.3.0 only);
    // rebind `text_` for the value you want to post.
    let req = { TweetCreateRequest.init {} with text_ = ?body };
    let resp = await* TweetsApi.createPosts(config, req);
    // `data` is optional on the response model; X only omits it on an error
    // path that `createPosts` would already have raised, so treat it as a bug.
    let ?data = resp.data else Runtime.trap("createPosts returned no data");
    data.id;
  };

  // ------------------------------------------------------------------
  // OAuth 2.0 PKCE flow.  `x-client` ships only the post-token call surface;
  // the OAuth handshake itself uses `ic.http_request` directly. Treat the
  // three functions below as the integration surface — implement them as
  // documented in the X OAuth 2.0 reference and persist the per-caller
  // code_verifier in actor state (a `Map<Principal, Text>` parallel to
  // `xAuthByUser`).
  //
  // See https://developer.x.com/en/docs/authentication/oauth-2-0/authorization-code
  // and `skills/connector-x/SKILL.md` (§ OAuth 2.0 setup) for the full handshake.
  // ------------------------------------------------------------------

  public func startAuthorize(clientId : Text, redirectUri : Text, caller : Principal) : async* Text {
    // 1. Generate a code_verifier (43-128 chars, [A-Za-z0-9-._~]).
    // 2. Persist it under `caller` in a `Map<Principal, Text>` actor field.
    // 3. Compute code_challenge = base64url(sha256(code_verifier)).
    // 4. Return: https://x.com/i/oauth2/authorize
    //              ?response_type=code
    //              &client_id={clientId}
    //              &redirect_uri={redirectUri}
    //              &scope=tweet.read+tweet.write+users.read+offline.access
    //              &state={fresh-csrf-token persisted alongside the verifier}
    //              &code_challenge={challenge}
    //              &code_challenge_method=S256
    let _ = clientId;
    let _ = redirectUri;
    let _ = caller;
    Runtime.trap("startAuthorize: implement OAuth 2.0 PKCE handshake (see comment block)");
  };

  public func exchangeCode(clientId : Text, code : Text, redirectUri : Text, caller : Principal) : async* XAuth {
    // POST https://api.x.com/2/oauth2/token (via ic.http_request, is_replicated=false)
    //   Content-Type: application/x-www-form-urlencoded
    //   body: grant_type=authorization_code
    //       & code={code}
    //       & redirect_uri={redirectUri}
    //       & client_id={clientId}
    //       & code_verifier={the verifier persisted in startAuthorize for `caller`}
    // Parse the JSON body, return XAuth { access_token; refresh_token;
    // expires_at = Time.now() + expires_in*1_000_000_000; scope }.
    let _ = clientId;
    let _ = code;
    let _ = redirectUri;
    let _ = caller;
    Runtime.trap("exchangeCode: implement OAuth 2.0 token exchange (see comment block)");
  };

  public func ensureFreshToken(clientId : Text, auth : XAuth) : async* XAuth {
    // If `Time.now() + 60s < auth.expires_at`, return auth unchanged.
    // Otherwise POST https://api.x.com/2/oauth2/token with
    //   grant_type=refresh_token & refresh_token={auth.refresh_token} & client_id={clientId}
    // X *rotates* refresh tokens — the response carries a new `refresh_token`
    // that supersedes the old one. ALWAYS persist the new pair (the
    // calling mixin handles the persist step).
    let _ = clientId;
    Runtime.trap("ensureFreshToken: implement RFC 6749 refresh (see comment block)");
  };
};
```

### Variant-specific invariants (admin Client ID)

- **Admin sets the Client ID, never the access token.** The Client ID
  is a public identifier; the per-user `access_token` is the secret.
  Two completely different storage shapes (`{ var value : ?Text }` vs
  `Map<Principal, XAuth>`) and two completely different gates
  (`#admin` vs "logged in").
- **No `getXClientId` endpoint.** `isXClientIdConfigured : Bool` is
  the only outward-facing read of `xClientId.value`. The frontend
  doesn't need to display the Client ID; it just needs to know whether
  to render the "Connect X" button.
- **`xAuthByUser` is per-caller only.** Same no-getter / no-log /
  no-iterate-outside-caller-scope invariants as `extension-openai`'s
  per-user variant. Concretely: never generate `getMyXAuth`, `getX`,
  `myAccessToken`, or any shared / query function whose return type is
  `?XAuth` / `?Text` / `Text`. A single `console.log` of an X bearer
  is a per-user account compromise.
- **Trap cleanly when missing prerequisites.** Three distinct
  conditions, three distinct messages: `"X is not configured"` (Client
  ID missing → admin task), `"Connect your X account first"` (user not
  yet authorised → frontend should kick off `startXOAuth`),
  `"Sign in to ..."` (anonymous caller → login required).

## 5. Two call shapes — function form vs. suite form

Same as `extension-openai`. Every Apis module ships both:

- **Function form** (used in §4): `TweetsApi.createPosts(config, req)
  : async* T`. Note the `async*` — call sites use `await*`. This is
  the common case for `shared` actor methods.
- **Suite form**: `let api = TweetsApi(config); api.createPosts(req)
  : async T`. Note `async`, not `async*`. Useful when a single
  `shared` method makes several X calls and you want to bind the
  config once.

The two forms are interchangeable; pick whichever reads cleaner. Don't
mix them inside the same `shared` body.

## 6. Available API surface

`[email protected]` ships a curated subset of the X API v2. The most
relevant module for this skill is `TweetsApi`:

| Module        | Primary entry point | What it does                                            |
| ------------- | ------------------- | ------------------------------------------------------- |
| `TweetsApi`   | `createPosts`       | Post a tweet (`/2/tweets`) — the 95% case for this skill. |
| `TweetsApi`   | `deleteTweetById`   | Delete a tweet (`/2/tweets/{id}`).                      |
| `UsersApi`    | `findMyUser`        | Get the authenticated user's handle/profile.            |

For X *reads* (timeline, search, lookup) the curated surface is much
smaller — `x-client` focuses on writes. Pull data from X via
`extension-http-outcalls` like any other public REST API.

If a build spec needs an X *write* not covered by `[email protected]`
(e.g. media upload, replies-to-replies semantics, retweet endpoints),
raise an issue on [`caffeinelabs/skills-internal`](https://github.com/caffeinelabs/skills-internal) — do not paper over it
with hand-rolled `ic.http_request`.

## 7. Cycles and response sizes

Leave `cycles` unset (`null`): each `x-client` call attaches the fee the IC
charges for that request, sized from the request actually sent, so
long-form tweets (premium subscribers, up to 25 000 chars) need no manual
budget, and the fee is zero on subnets where outcalls are free. `?n`
attaches exactly `n` cycles and fails on a subnet where the canister holds
fewer.

## 8. Things that will bite you

- **`is_replicated = ?false`** — see §3. Not optional.
- **`x-client < 0.3.0`** — older versions emit `"field": null` for
  every absent optional, and `/2/tweets` rejects them with up to 16
  validation errors per request. 0.3.0 ships the `init` constructors
  that default optionals to `null` *in Motoko* and elide them on the
  wire (via `serde-core@^0.1.2`'s `skip_null_fields`).
- **Don't expose the access token.** `xAuthByUser` is read only by
  `Map.get(xAuthByUser, ..., caller)` inside `tweet` /
  `ensureFreshToken`. No `getMyXAuth`, no `getMyAccessToken`, no
  iterator. A leaked bearer is a per-user account compromise.
- **Persist the rotated refresh token.** X returns a new
  `refresh_token` with every refresh (`grant_type=refresh_token`); if
  you keep using the old one, the next refresh will 400. The mixin in
  §4 handles this — the `if (fresh.access_token != auth.access_token)`
  branch persists the new pair.
- **Token expiry** is ~2 hours. If you omit `offline.access` from the
  authorise scopes, you will not get a `refresh_token` and the user
  must re-authorise every time.
- **Callback URI mismatch.** Every character (trailing slash, query
  string, port) must match the URI registered on the Developer Portal.
  X returns a generic `redirect_uri_mismatch` error otherwise.
- **Don't roll your own JSON.** `x-client` already handles the
  request/response JSON via `JSON.toCandid` / `JSON.fromCandid` and
  `serde-core`'s null-elision.
- **No `getApiKey`-style endpoint, ever.** Same rule as
  `extension-openai`'s per-user variant: every shared / query function
  that returns `?XAuth`, `?Text` (the access token), or any prefix of
  the bearer is a leak.
- **Rate limits.** `/2/tweets` is capped per-user-per-app. Replicated
  outcalls would multiply RPM by the subnet size — yet another reason
  for `is_replicated = ?false`. Back off on HTTP 429.
- **Frontend never holds tokens.** The React app calls the backend
  `tweet(body)` and the backend mediates everything. The OAuth flow
  itself uses **redirect-and-back** through `x.com` — the frontend
  starts the flow via `startXOAuth(redirectUri)` and finishes via
  `completeXOAuth(code, redirectUri)`; the tokens never reach the
  browser.

## 9. Variant: per-user Client ID

Use this variant when each end-user must bring their own X Developer
App (multi-tenant rate-limit isolation, per-user Developer Portal
control). Mechanically the Client ID storage flips from a single
`{ var value : ?Text }` (admin-set) to a `Map<Principal, Text>`
(per-user); the OAuth + posting mixin from §4 reuses unchanged
modulo the Client ID lookup.

The actor keeps the same shape — drop the admin-Client-ID mixin,
add a per-user-Client-ID one:

```motoko filepath=src/backend/per-user-clientid-main.mo
import Map "mo:core/Map";
import Principal "mo:core/Principal";
import AccessControl "mo:caffeineai-authorization/access-control";
import MixinAuthorization "mo:caffeineai-authorization/MixinAuthorization";
import MixinXClientIdPerUser "mixins/x-clientid-per-user";
import MixinXPostingPerUserClientId "mixins/x-posting-per-user-clientid";
import LibX "lib/x";

actor {
  let accessControlState : AccessControl.AccessControlState;
  include MixinAuthorization(accessControlState, null);

  // Per-user X Developer App Client IDs.
  let xClientIdByUser : Map.Map<Principal, Text>;
  include MixinXClientIdPerUser(xClientIdByUser);

  // Per-user OAuth tokens — same shape as §4.
  let xAuthByUser : Map.Map<Principal, LibX.XAuth>;
  include MixinXPostingPerUserClientId(xClientIdByUser, xAuthByUser);
};
```

This variant's migration chain head replaces §4's (same
`src/backend/migrations/00000000_000000.mo` path in a real app —
in this variant `xClientIdByUser` takes the place of `xClientId`):

<!-- motoko-check:skip -->
```motoko
import Map "mo:core/Map";
import AccessControl "mo:caffeineai-authorization/access-control";

module {
  type XAuth = {
    access_token : Text;
    refresh_token : Text;
    expires_at : Nat64;
    scope : [Text];
  };

  type NewActor = {
    accessControlState : AccessControl.AccessControlState;
    xClientIdByUser : Map.Map<Principal, Text>;
    xAuthByUser : Map.Map<Principal, XAuth>;
  };

  public func migration(_old : {}) : NewActor {
    {
      accessControlState = AccessControl.initState();
      xClientIdByUser = Map.empty<Principal, Text>();
      xAuthByUser = Map.empty<Principal, XAuth>();
    };
  };
};
```

The two mixin files are mechanical adaptations of §4's:

- `mixins/x-clientid-per-user.mo` swaps the admin gate for a
  signed-in-caller gate: `setMyXClientId(id) : async ()` writes the
  caller's slot of `xClientIdByUser`; `isMyXClientIdConfigured` reads
  the same slot.
- `mixins/x-posting-per-user-clientid.mo` looks up the Client ID by
  `caller` instead of reading the single `{ var value : ?Text }` —
  every...

© caffeinelabs · Apache-2.0 · caffeinelabs/skills

Похожие скиллы

Всё по теме «Бэкенд и API» →
xurl xdevplatform Скилл для взаимодействия с API X через утилиту xurl. Позволяет публиковать записи, искать посты, читать ленту и отправлять сообщения. CX Codex +1 660установок x-api-integration VoltAgent Community Помогает строить поиск, мониторинг и аналитику по публичным данным X: разбирает авторизацию, лимиты, пагинацию и вебхуки. ★ 26 065звёзд репо x-twitter-scraper официальный github Помогает настроить работу с X API и парсерами через Xquik, Apify Actors или MCP-сервер. Пригодится для выгрузки твитов, подписчиков и обработки вебхуков. 80установок clerk-chrome-extension-patterns clerk Набор инструкций по интеграции @clerk/chrome-extension в браузерные расширения. Помогает настроить вход в popup, сессии в service worker и делегирование OAuth через веб-приложение. CX Codex +1 14 400установок create-auth better-auth Скилл проводит агента через планирование и настройку аутентификации в TypeScript/JavaScript-проекте на Better Auth: от сканирования кода до страниц входа. CX Codex +1 13 400установок mcp-server-offering Samuel Berthe Помогает спроектировать MCP-сервер продукта как поверхность для ИИ-агентов: какие инструменты отдавать, под какими лимитами и как измерять отдачу. CX Codex +1 8 000установок

Из того же набора25

extension-email-marketing Добавляет в приложение на Caffeine AI маркетинговые письма по темам: подписчики, токены отписки и страница /unsubscribe на своём фронтенде. CX Codex +1 28 100установок extension-camera Добавляет в агент правила работы с веб-камерой через React-хук useCamera. Пригодится для съёмки фото и видеопревью в браузере. CX Codex +1 28 100установок extension-qr-code Добавляет в React-приложение сканирование QR-кодов через камеру устройства. Пригодится, когда нужно считать код прямо в браузере. CX Codex +1 28 200установок extension-stripe Добавляет в проект на Caffeine AI оплату картами через Stripe: создание checkout-сессии и проверку её статуса. Пригодится, когда нужен приём платежей внутри канистры. CX Codex +1 28 200установок extension-openai Задаёт правила интеграции OpenAI в канистры Motoko через пакет openai-client. Защищает API-ключ от утечки при реплицированных вызовах. CX Codex +1 28 100установок extension-email-raw Скилл добавляет в проект отправку одного письма сразу нескольким получателям через to, cc и bcc. Пригодится, когда нужно разослать уведомление участникам встречи. CX Codex +1 28 100установок extension-invite-links Скилл добавляет в проект систему приглашений и сбор ответов RSVP. Гости отправляют подтверждения по кодам без авторизации, а администратор видит сводку через вход в систему. CX Codex +1 28 100установок extension-http-outcalls Скилл для HTTP-запросов из бэкенд-канистры к внешним API. Следит, чтобы каждый запрос был ограничен на стороне провайдера. CX Codex +1 28 100установок

Спонсоры проекта

Компании, которые помогают держать аналитику открытой