npx skills add ...
npx skills add caffeinelabs/skills --skill 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-tweeting, posting-to-X, posting-a-status, sharing-to-Twitter, or any equivalent phrasing — and BEFORE writing any code that touches `api.x.com`.
npx skills add caffeinelabs/skills --skill extension-posting-to-x
X / Twitter posting extension for Caffeine AI.
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.
Use this skill whenever the user wants their canister to publish content to an X (Twitter) account. The ingredients are:
x-client mops package (generated Motoko bindings for the X API
v2; the spec subset includes TweetsApi.createPosts and friends).access_token + refresh_token keyed by caller : Principal. There is no canister-wide bearer.Config value that pins is_replicated = ?false — non-negotiable,
see §3.Prerequisite for all variants: extension-authorization.
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.
x-client to mops.tomlUse the mops tool, not manual file edits:
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.
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).
| 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 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.
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:
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.Map.get(xAuthByUser, ..., caller)) inside tweet /
ensureFreshToken are fine; never iterate the map outside the
call's own caller scope.is_replicated = ?false is REQUIREDSame priority order as extension-openai's §3:
Authorization: Bearer <access_token>. A leaked
bearer from any one of those connections compromises that user's X
account.tweet call quickly
trips X's rate limit.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.
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 + 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.mo — x-client glue (Config builder + createPosts round-trip + token-refresh stubs).The migration chain head:
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").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."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).Same as extension-openai. Every Apis module ships both:
TweetsApi.createPosts(config, req) : async* T. Note the async* — call sites use await*. This is
the common case for shared actor methods.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.
x-client@0.2.3 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 x-client@0.2.3
(e.g. media upload, replies-to-replies semantics, retweet endpoints),
raise an issue on caffeinelabs/x-client — do not paper over it
with hand-rolled ic.http_request.
defaultConfig.cycles = 30_000_000_000 — about 0.04 USD at 4 USD/T
cycles. Sufficient for a typical createPosts call. Bump for:
cycles = 60_000_000_000./2/oauth2/token) is small; the
default cycle budget is generous.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).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.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.offline.access from the
authorise scopes, you will not get a refresh_token and the user
must re-authorise every time.redirect_uri_mismatch error otherwise.x-client already handles the
request/response JSON via JSON.toCandid / JSON.fromCandid and
serde-core's null-elision.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./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.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.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:
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):
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.
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:
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.
Surfaces every build that uses this skill must ship:
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:
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.
A Client ID configuration surface. Variant-specific:
/settings/x page
with a single password-input bound to setXClientId(id)./settings/x page reachable
to any signed-in user, bound to setMyXClientId(id).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).
Two pages:
Admin settings page — /settings/x (admin-gated):
setXClientId(id). Submit on enter;
clear the input on success.isXClientIdConfigured() (returns
Bool). Show "Configured" / "Not configured" — never display
the Client ID itself, never expose a getter that returns it.extension-authorization'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.Connect X page — /connect/x (any signed-in user):
?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).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).disconnectMyX().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:
Two pages, both reachable to any signed-in user:
My X settings page — /settings/x:
setMyXClientId(id). Same no-display
invariant.isMyXClientIdConfigured().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:
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).
/settings/... and /connect/x routes through
extension-authorization'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 ...".localStorage,
no IndexedDB, no cookies — the canister mediates everything.
The browser only ever sees Bool status flags
(isMyXConnected, isXClientIdConfigured) and the OAuth
redirect URLs.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.tweet /
history endpoints the canister exposes. No client-side X SDK, no
token handling, no JSON serialisation logic — the canister is
the X client.mops add x-client@0.2.3 — 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)./2/tweets API reference — what createPosts actually hits.useInternetIdentity / useActor frontend plumbing, and the #admin role gate for variants §4 and §11.