caffeinelabs/skills

extension-posting-to-x

>- 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-twee…

All-time #1015 Trending #723 Hot #3551 First seen Jun 17, 2026
8-week activity · all time api

Installation

$ npx skills add caffeinelabs/skills --skill extension-posting-to-x

Similar popular skills

Related neighbors and high-traction skills in the same topics — useful to compare before installing.

Also in this package

Other skills from caffeinelabs/skills · top by installs.

npx skills add caffeinelabs/skills

Browse all from caffeinelabs/skills

More details

Agent compatibility

Declared targets from SKILL.md / docs. Unmarked agents are not listed — the skill may still install via the CLI.

Claude Code Not declared
Cursor Not declared
Codex Not declared
GitHub Copilot Not declared
Windsurf Not declared
Gemini CLI Not declared
Cline Not declared
OpenCode Not declared

Repository health

License LICENSE
Default branch main
Open issues 6
Status Active

Skill metadata

Parsed from SKILL.md frontmatter.

Version0.1.1

Package contents

Files included with this skill beyond the listing page.

  • skill md SKILL.md 38,464 B
  • docs SUMMARY.md 670 B

History

  1. First seen on skills.sh
  2. First recorded snapshot · 18,700 installs

SKILL.md

Posting to X

X / Twitter posting extension for Caffeine AI.

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 queryrag for tweeting / posting requests. The x-client mops connector is the only supported path; raw ic.httprequest 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).

  1. 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 accesstoken + refreshtoken keyed by caller : Principal. There is no canister-wide bearer.

  1. 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.

  1. 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:

mops add [email protected]

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

Minimum version: x-client ≥ 0.2.3. Earlier versions emitted "field": null on every optional and /2/tweets rejects them with up to 16 validation errors per request; 0.2.3 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 accesstoken + refreshtoken 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 accesstoken / refreshtoken / 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.

  1. 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.

  1. 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 accesstoken + refreshtoken.

The example spans five files:

  • src/backend/main.mo — the actor: state + includes 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.mox-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 = { accesstoken : Text; refreshtoken : Text; expires_at : Nat64; // ns absolute (Time.now()-relative) scope : [Text]; };

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

public func runCreatePost(config : Config, body : Text) : async Text { // TweetCreateRequest.init() returns a record with every optional set // to null (≥ 0.2.3 only); rebind text for the value you want to post. let req = { TweetCreateRequest.init() with text = ?body }; let resp = await TweetsApi.createPosts(config, req); resp.data.id; };

// ------------------------------------------------------------------ // OAuth 2.0 PKCE flow. x-client ships only the post-token call surface; // the OAuth handshake itself uses ic.httprequest 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 // codeverifier in actor state (a Map<Principal, Text> parallel to // xAuthByUser). // // See https://developer.x.com/en/docs/authentication/oauth-2-0/authorization-code // and the package's skills/oauth-setup.md for the full handshake. // ------------------------------------------------------------------

public func startAuthorize(clientId : Text, redirectUri : Text, caller : Principal) : async* Text { // 1. Generate a codeverifier (43-128 chars, [A-Za-z0-9-.~]). // 2. Persist it under caller in a Map<Principal, Text> actor field. // 3. Compute codechallenge = base64url(sha256(codeverifier)). // 4. Return: https://x.com/i/oauth2/authorize // ?responsetype=code // &clientid={clientId} // &redirecturi={redirectUri} // &scope=tweet.read+tweet.write+users.read+offline.access // &state={fresh-csrf-token persisted alongside the verifier} // &codechallenge={challenge} // &codechallengemethod=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.httprequest, isreplicated=false) // Content-Type: application/x-www-form-urlencoded // body: granttype=authorizationcode // & code={code} // & redirecturi={redirectUri} // & clientid={clientId} // & codeverifier={the verifier persisted in startAuthorize for caller} // Parse the JSON body, return XAuth { accesstoken; refreshtoken; // expiresat = Time.now() + expiresin1000000000; 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.expiresat, return auth unchanged. // Otherwise POST https://api.x.com/2/oauth2/token with // granttype=refreshtoken & refreshtoken={auth.refreshtoken} & clientid={clientId} // X rotates* refresh tokens — the response carries a new refreshtoken // 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/x-client`](https://github.com/caffeinelabs/x-client) — do not paper over it
with hand-rolled `ic.http_request`.

## 7. Cycles and response sizes

`defaultConfig.cycles = 30_000_000_000` — about 0.04 USD at 4 USD/T
cycles. Sufficient for a typical `createPosts` call. Bump for:

- Long-form tweets (premium subscribers, up to 25 000 chars): set
  `cycles = 60_000_000_000`.
- The OAuth token-exchange call (`/2/oauth2/token`) is small; the
  default cycle budget is generous.

## 8. Things that will bite you

- **`is_replicated = ?false`** — see §3. Not optional.
- **`x-client < 0.2.3`** — older versions emit `"field": null` for
  every absent optional, and `/2/tweets` rejects them with up to 16
  validation errors per request. 0.2.3 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 -->

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 other line is identical to mixins/x-posting.mo from §4.

Same no-getter rule: there is no getMyXClientId endpoint, even though the Client ID is technically public — keeping the boundary consistent with the access-token rule trains the agent not to grep the codebase for "key" / "id" and add a getter.

10. Variant: fallback (admin default + per-user override)

Use this when the operator wants to provide a no-config path for casual users while letting power users self-register. The admin sets a canister-wide default Client ID; individual users may override it with their own.

Lookup order at OAuth start time:

func clientIdFor(caller : Principal) : ?Text = switch (Map.get(xClientIdByUser, Principal.compare, caller)) {
  case (?id) ?id;
  case null adminClientId.value; // may itself be null → caller must provide one
};

Ship both mixins from §4 and §10 in the same actor: admin sets the default via setXClientId, users override via setMyXClientId. startXOAuth calls clientIdFor(caller) instead of reading the single slot. Everything else (xAuthByUser, the OAuth handshake, the posting endpoint) is unchanged.

Frontend

Surfaces every build that uses this skill must ship:

  1. A login flow — required for every variant. X cannot work

without a non-anonymous caller; the per-user OAuth handshake stores tokens keyed by caller : Principal, and the admin / per-user Client ID setters all gate on a logged-in caller. The login flow itself comes from [extension-authorization](../extension-authorization/SKILL.md): useInternetIdentity, the login/logout buttons, the useActor plumbing that injects the authenticated identity into every backend call. Plan a sign-in screen as part of the same task graph if the build doesn't already have one.

  1. A Client ID configuration surface. Variant-specific:

- Admin variant (§4 default): an admin-gated /settings/x page with a single password-input bound to setXClientId(id). - Per-user variant (§9): a personal /settings/x page reachable to any signed-in user, bound to setMyXClientId(id). - Fallback variant (§10): both pages — admin-gated for the default and per-user for the override.

  1. A "Connect X" page — always. A per-user, not admin-gated

page that runs the OAuth 2.0 PKCE handshake: kicks off via startXOAuth(redirectUri), redirects the browser to X for consent, lands back on the same page with ?code=..., calls completeXOAuth(code, redirectUri) to exchange the code for tokens. End-state is "X connected as @handle" or "Connect X" depending on isMyXConnected().

Pick the UI shape that matches the backend variant. Default to Variant A (admin Client ID + per-user OAuth) unless the spec explicitly chooses per-user (§9) or fallback (§10).

Variant A: admin Client ID + per-user OAuth (matches §4 — default)

Two pages:

  1. Admin settings page/settings/x (admin-gated):

- Password-input bound to setXClientId(id). Submit on enter; clear the input on success. - Status indicator driven by isXClientIdConfigured() (returns Bool). Show "Configured" / "Not configured" — never display the Client ID itself, never expose a getter that returns it. - Hide from non-admins via [extension-authorization](../extension-authorization/SKILL.md)'s isCallerAdmin query — non-admins should not see the link in the nav, let alone the page. Bind admin-only routes through your router's guard pattern.

  1. Connect X page/connect/x (any signed-in user):

- "Connect X" button bound to startXOAuth(window.location.origin + '/connect/x'). The button redirects the browser to the URL returned by the canister. - On the return leg, parse ?code=...&state=... from the URL, call completeXOAuth(code, redirectUri) (same redirectUri that was passed to startXOAuth), then redirect to wherever the user came from (or home). - Status driven by isMyXConnected() (returns Bool). Show "Connected as @…" (the handle is not fetched from the bearer — fetch it separately via a getMyXHandle endpoint that calls UsersApi.findMyUser, never decode the bearer in JS). - Optional "Disconnect X" button bound to disconnectMyX().

  1. Empty-state nudge on the post-tweet UI — when

isMyXConnected() is false, render an inline "Connect X to post" link to /connect/x. Without this nudge users hit "Connect your X account first" with no obvious next step.

Suggested route layout:

/                   →  Main UI (any signed-in user; empty-state when no X connection)
/settings/x         →  Admin Client ID config (admin-only)
/connect/x          →  Per-user OAuth handshake (any signed-in user)

Variant B: per-user Client ID (matches §9)

Two pages, both reachable to any signed-in user:

  1. My X settings page/settings/x:

- Password-input bound to setMyXClientId(id). Same no-display invariant. - Status driven by isMyXClientIdConfigured(). - No router guard beyond "logged in".

  1. Connect X page — same as Variant A's /connect/x, except

startXOAuth uses the user's own Client ID under the hood. The user must configure their Client ID before connecting.

Suggested route layout:

/                   →  Main UI
/settings/x         →  Personal Client ID (any signed-in user)
/connect/x          →  Per-user OAuth handshake

Variant C: fallback (matches §10)

Three pages:

  • /admin/settings/x (admin-gated) — setXClientId for the

canister-wide default.

  • /settings/x (any signed-in user) — setMyXClientId for the

per-user override.

  • /connect/x (any signed-in user) — same OAuth handshake as

Variants A/B, with the lookup order described in §10.

The "Connect X" button stays disabled until some Client ID is resolvable for the caller (admin default OR per-user override).

Common to all variants

  • Sign-in is required for every X-related route. Wire the

/settings/... and /connect/x routes through [extension-authorization](../extension-authorization/SKILL.md)'s auth guard (useInternetIdentity + a redirect when !isAuthenticated); anonymous callers must hit a "please sign in" wall before any backend call fires, otherwise every endpoint traps with "Sign in to ...".

  • The frontend never persists tokens. No localStorage,

no IndexedDB, no cookies — the canister mediates everything. The browser only ever sees Bool status flags (isMyXConnected, isXClientIdConfigured) and the OAuth redirect URLs.

  • The OAuth state parameter is the canister's responsibility.

Generate it server-side in startXOAuth, persist it alongside the code_verifier, verify it in completeXOAuth before exchanging the code. Do not let the frontend mint or echo state — that defeats CSRF protection.

  • The post-tweet UI itself is trivial: a textarea, a submit

button, a list of recent tweets bound to whatever tweet / history endpoints the canister exposes. No client-side X SDK, no token handling, no JSON serialisation logic — the canister is the X client.

Related

  • mops add [email protected] — connector source.
  • caffeinelabs/x-client — generated bindings repo. Its skills/oauth-setup.md carries the authoritative step-by-step Developer Portal walkthrough; its skills/tweeting-fine-points.md documents operational gotchas (minimum version, scopes, replication, null-field serialisation, sub-object rules).
  • X Developer Portal — where the Client ID is created.
  • OAuth 2.0 Authorization Code with PKCE (X docs) — canonical authorise/token endpoint details.
  • /2/tweets API reference — what createPosts actually hits.
  • RFC 7636 — Proof Key for Code Exchange — PKCE spec.
  • [extension-authorization](../extension-authorization/SKILL.md) — required prerequisite for every variant of this skill. Provides the Internet Identity login flow, the useInternetIdentity / useActor frontend plumbing, and the #admin role gate for variants §4 and §11.
  • [extension-http-outcalls](../extension-http-outcalls/SKILL.md) — sibling skill for general HTTP outcalls, including X reads (timeline, search, lookup) which this skill does NOT cover.