Farcaster / Warpcast (wrapcast)
Farcaster is a decentralized social protocol; Warpcast is its best-known client.
Rustiz publishes through Neynar, which manages the Farcaster signer. The
connect flow is not OAuth: the server creates a Neynar signer, signs a
SignedKeyRequest with the app’s custody mnemonic, shows the human an approval
link/QR code, and polls until the signer is approved in the Farcaster app.
- Rust source:
crates/rustiz-providers/src/farcaster.rs - Upstream port:
farcaster.provider.ts - Editor: Normal
- Connection mode:
signer - Identifier is
wrapcast, notfarcaster.
Overview
| Property | Value |
|---|---|
| Identifier | wrapcast |
| Display name | Farcaster |
| Connection mode | signer (server-created Neynar signer, human approval) |
| Editor | normal |
| Channel kind | social |
| Comments / threads | Advertised as no: supports_comments is left false, even though comment() is implemented. The engine truncates extra thread entries and MCP validation reports “does not support thread replies”. |
| Pending / async publishing | No. supports_pending = false; post() calls Neynar and returns Posted |
| Analytics | No |
| Post analytics | No |
| Mentions | No |
is_between_steps | false |
convert_to_jpeg | false |
max_concurrent_job | 3 |
Character limit (max_length) | 800 characters, hard-coded |
| Web3 flag | is_web3 = true |
| Scopes | None |
| Redirect URI | None; the approval URL is a farcaster:// deep link |
Server Environment Variables
required_env() returns four variables:
#![allow(unused)]
fn main() {
&[
"NEYNAR_CLIENT_ID",
"NEYNAR_APP_FID",
"NEYNAR_APP_MNEMONIC",
"NEYNAR_SECRET_KEY",
]
}
required_env_alternatives() is not overridden.
| Variable | Required | Default | Purpose |
|---|---|---|---|
NEYNAR_CLIENT_ID | Yes | — | Neynar app client id. Only used by the legacy generate_auth_url() (client id, two vertical bars, state) and the readiness check; the headless signer flow does not use it. |
NEYNAR_APP_FID | Yes | — | Farcaster FID of the app account whose custody mnemonic signs the signer request. Parsed as i64; a non-number is a configuration error. |
NEYNAR_APP_MNEMONIC | Yes | — | BIP-39 mnemonic of the app account’s custody address. Signs the EIP-712 SignedKeyRequest. Secret. |
NEYNAR_SECRET_KEY | Yes | — | Neynar API key, sent in the api_key header by the Rust code |
NEYNAR_BASE_URL | No | https://api.neynar.com | Neynar API origin; trailing slash trimmed |
NEYNAR_SPONSOR_SIGNERS | No | unset | When exactly "true", adds sponsor: {sponsored_by_neynar: true} to the signed-key registration. Neynar charges the developer’s credits. |
JWT_SECRET | No direct use | empty | Not read by the Farcaster flow. The signer uuid is stored as the integration token; the pending {signerUuid, approvalUrl} row is plaintext. Listed only because the rest of the server needs it. |
rustiz providers --status requires all four variables and reports
wrapcast as missing until they are set. NEYNAR_CLIENT_ID is required by
that check even though the headless flow does not use it; set any non-empty
value if your Neynar app does not expose one (for example, the client id shown
in the dashboard).
The fallback API key inside api_key() is the placeholder
00000000-000-0000-000-000000000000. It is only used when
NEYNAR_SECRET_KEY is unset, which provider_status() normally prevents.
Step-by-Step Developer App Setup
1. Create a Neynar account and app
- Sign in at https://dev.neynar.com.
- Create an app. Neynar returns a Client ID and a Secret Key. The
secret key is the API key used for every
/v2/farcaster/...call. - Note the app’s FID and the custody mnemonic of the Farcaster account used as the app. Neynar’s app settings expose the FID; the mnemonic is the seed phrase of that FID’s custody address. If the FID was created in another wallet, use that wallet’s mnemonic instead. The mnemonic is used only to sign signer requests; it is not a user mnemonic.
- Put the values in the environment:
NEYNAR_CLIENT_ID="…"
NEYNAR_APP_FID="12345"
NEYNAR_APP_MNEMONIC="word word word …"
NEYNAR_SECRET_KEY="…"
- Restart
rustiz serve,rustiz workerandrustiz mcp; they read the configuration at startup.
2. What the app FID and mnemonic are for
Farcaster requires an app to prove it controls an FID before a signer can be
registered for a user. Rustiz derives the app’s Ethereum key from the mnemonic
at m/44'/60'/0'/0/0 (BIP-39 seed, empty passphrase, BIP-32) and signs the
EIP-712 payload:
Domain:
name: "Farcaster SignedKeyRequestValidator"
version: "1"
chainId: 10
verifyingContract: 0x00000000FC700472606eD4Fa22623AcF62c60553
Type:
SignedKeyRequest(uint256 requestFid, bytes key, uint256 deadline)
The signature is 65 bytes (r, s, v+27), 0x-prefixed. If the mnemonic is
wrong, Neynar rejects the signed-key registration with a 4xx; the connect call
fails before a human sees an approval link.
The implementation is a small self-contained BIP-39/BIP-32 + secp256k1 +
Keccak-256 signer; it does not use viem or an Ethereum SDK.
3. Redirect URI
There is no redirect URI. start_farcaster() in
crates/rustiz-server/src/connect.rs never calls generate_auth_url(). The
human approval is a deep link into the Farcaster app:
farcaster://signed-key-request?token=0x…
The provider also renders the link as a PNG QR data URL. No domain, no
FRONTEND_URL callback and no redirectmeto.com are involved.
4. Scopes
Farcaster/Neynar does not use OAuth scopes here. info.scopes is empty.
check_scopes() is never called. Access is controlled by the Neynar API
key and the approved signer’s permissions.
5. Sandbox / testing vs production
There is no Farcaster “sandbox” and no app review. The gates are:
- Signer approval. Every user must approve the signer once in the Farcaster
app. That is an onchain transaction; Neynar charges for it (or the developer
sponsors it with
NEYNAR_SPONSOR_SIGNERS=trueand pays in credits). Neynar’s own documentation warns: “While testing please reuse the signer, it costs money to approve a signer.” - Neynar plan/credits. Read and write endpoints are metered by the Neynar plan. A free/dev plan is enough to test with a few accounts; production volume needs a paid plan.
- App FID. A production app should use a dedicated Farcaster account/FID for the app, not a personal account, because the custody mnemonic signs every signer request.
For tests:
- Connect once with a test Farcaster account and keep the resulting channel.
- Do not delete/reconnect repeatedly: each connect attempt creates a new signer and can cost another approval.
- If you already have an approved Neynar signer, skip signer creation with
customFields.signerUuid(see below).
Connecting a Channel (CLI & MCP)
Flow A — create a new signer (normal)
CLI
rustiz channels connect wrapcast
The command prints a JSON document (stdout) with:
{
"connected": false,
"pending": true,
"connectionId": "fc_…",
"approvalUrl": "farcaster://signed-key-request?token=0x…",
"qrCode": "data:image/png;base64,…",
"message": "Give this link (or QR code) to the human: …"
}
The CLI does not wait for the approval, because there is no browser
callback. Give the approvalUrl or qrCode to the human, then poll:
rustiz channels connect-status fc_…
The connection id lives for 30 minutes. Polling does not extend the TTL; if it expires, start again (and pay for a new signer).
MCP connectChannelTool
{ "provider": "wrapcast" }
Returns the same connectionId, approvalUrl and qrCode. Poll with:
{ "connectionId": "fc_…" }
integrationSchema reports:
{
"type": "signer",
"headless": true,
"requiresHuman": true,
"requires": [
"NEYNAR_CLIENT_ID",
"NEYNAR_APP_FID",
"NEYNAR_APP_MNEMONIC",
"NEYNAR_SECRET_KEY"
]
}
Flow B — reuse an approved signer (customFields.signerUuid)
If a Neynar signer was already created and approved (for example, by another Neynar app using the same API key), pass its uuid:
{
"provider": "wrapcast",
"customFields": {
"signerUuid": "19d0c5fd-9b33-4a48-a0e2-bc7b0555baec"
}
}
CLI:
printf '%s' '{"signerUuid":"19d0c5fd-9b33-4a48-a0e2-bc7b0555baec"}' \
| rustiz channels connect wrapcast --fields -
The server:
- Requires
NEYNAR_SECRET_KEY. - Calls
GET /v2/farcaster/signer?signer_uuid=…. - If the status is
approvedand the response has anfid, builds the same base64 user payload and connects immediately. - If the signer is not approved, returns an actionable error such as
That Farcaster signer is not approved yet (status: pending_approval). Open its approval link in the Farcaster app first.
This path only checks NEYNAR_SECRET_KEY. It does not need
NEYNAR_APP_FID, NEYNAR_APP_MNEMONIC or NEYNAR_CLIENT_ID at runtime. The
normal signer-creation path calls provider_status() and requires all four, so
in practice set all four.
Signer approval details
The server’s sequence (Farcaster::create_signer()):
POST /v2/farcaster/signerwith{}→signer_uuid,public_key,status: generated.deadline = now + 24 hours.- Sign the EIP-712
SignedKeyRequestwith the app mnemonic. POST /v2/farcaster/signer/signed_keywith:
{
"signer_uuid": "…",
"app_fid": 12345,
"deadline": 1760000000,
"signature": "0x…",
"sponsor": { "sponsored_by_neynar": true }
}
sponsor is present only when NEYNAR_SPONSOR_SIGNERS=true.
- Read
signer_approval_urlandsigner_uuid; buildqrCode = qr_data_url(approval_url)(PNG data URL, 4 px modules, 4-module margin). - Store
{signerUuid, approvalUrl}in theoauth_statesrow ascode_verifier,provider="wrapcast", TTL 30 minutes.
When the human approves in the Farcaster app, the server polls:
GET /v2/farcaster/signer?signer_uuid=….- If
status != "approved", return pending (or an error ifrevoked). - If approved, read
fid. GET /v2/farcaster/user/bulk?fids=<fid>to getusername,display_name,pfp_url.- Build a base64 JSON payload:
{
"signer_uuid": "…",
"fid": 12345,
"username": "alice",
"display_name": "Alice",
"pfp_url": "https://…"
}
authenticate()decodes it and stores:id= FIDaccess_token=signer_uuidrefresh_token=""expires_in= 200 yearsusername= Farcaster usernamename= display namepicture= pfp URL
The signer uuid is the credential used for every cast. There is no token refresh.
Do not send customFields without signerUuid
connect.rs treats any customFields object as the credential path. For
wrapcast, the object must contain signerUuid (or signer_uuid); otherwise
the flow returns Farcaster customFields needs signerUuid of an already-approved Neynar signer.
Post Settings & Media Validation
settings_schema()
The field is named subreddit for historical reasons (copied from Reddit), but
it holds Farcaster channels:
{
"type": "object",
"properties": {
"subreddit": {
"type": "array",
"description": "Farcaster channels to cast into (from the subreddits tool). Leave empty to cast on the user's timeline.",
"items": {
"type": "object",
"properties": {
"value": {
"type": "object",
"properties": {
"id": { "type": "string", "description": "Channel id." }
},
"required": ["id"],
"additionalProperties": true
}
},
"required": ["value"],
"additionalProperties": true
}
}
},
"additionalProperties": true
}
There is no required at the top level. subreddit may be omitted, null or
an empty array; all three mean “post to the user’s timeline”.
validate_settings() rules
- If
subredditis present and notnull, it must be an array. - Each entry must be an object.
- Each entry must have a
valuethat is an object. value.idmust be a string.- Extra properties are allowed at every level.
It does not check that the channel exists, that the id is non-empty, or that the user can post in it. Neynar returns the error at publish time.
check_validity()
Only one rule:
#![allow(unused)]
fn main() {
if posts.iter().any(|items| {
items.iter().any(|item| item.path.contains("mp4"))
}) {
return Ok(Some("Can only accept images".into()));
}
}
- Any media path containing
mp4(case-sensitive) rejects the whole post. - No check on image count, size, format or URL reachability.
- The check runs for every entry in the thread, even though threads are
effectively disabled (
supports_comments = false).
Media requirements
Farcaster casts support up to 2 embeds, each a URL up to 256 bytes. Rustiz
does not upload media: it maps every MediaContent.path to:
{ "url": "https://cdn.example/image.png" }
and sends them in embeds to POST /v2/farcaster/cast. Consequences:
- The URL must already be publicly reachable; a local file path will not work.
- Rustiz does not cap embeds at 2. Three or more media items produces a request Neynar/Farcaster rejects.
- Alt text, thumbnails and video are ignored.
- Images are the only supported type;
check_validity()blocks.mp4, but a.MP4(uppercase) bypasses it and is sent as a URL embed. - A cast can have text plus embeds; an empty text with embeds may be accepted,
but Rustiz always sends
text(possibly empty).
Channels
cast_ids()readsposts.first().settings["subreddit"].null, a non-array, or an empty array → one cast with nochannel_id(timeline).- A non-empty array → one cast per
value.id, each withchannel_id. post_idandrelease_urlare comma-joined across channels. The engine stores the whole string;comment()splits it on commas to reply to every cast.- If one channel cast fails, the whole
post()returns an error. Casts already published to earlier channels remain live; there is no rollback. - The same applies to replies:
comment()sends one reply per parent id and stops at the first failure.
Plugs
No @Plug / @PostPlug methods. run_plug() and run_post_plug() return
Unsupported.
triggerTool: channel search
functions() declares one function:
| Function | Params | Returns | Notes |
|---|---|---|---|
subreddits | { "word": "search term" } | [{"title":"…","name":"…","id":"…"}] | GET /v2/farcaster/channel/search?q=<word>&limit=10, maps Neynar channels |
MCP:
{
"integrationId": "int_…",
"methodName": "subreddits",
"dataSchema": [{ "key": "word", "value": "developers" }]
}
Use the returned id as subreddit[].value.id. There is no
channelSetupTool: is_between_steps is false, so the channel is selected
per post, not at connect time.
Implementation Notes & Gotchas
- Threads are implemented but not advertised.
comment()is a full implementation (reply to each parent cast with optional embeds), yetinfo.supports_commentsis never set totrue. The engine truncates a multi-entry post to the first entry, and MCPvalidatePostToolreports “Farcaster does not support thread replies”. If you need threads, setinfo.supports_comments = trueinFarcaster::new(); the code path already exists. post()only reads the firstPostDetails. Even if called directly with several entries, it publishes one cast per configured channel for the first entry and ignores the rest. The engine truncates the chain first becausesupports_comments = false.NEYNAR_CLIENT_IDis dead weight in the headless flow. It is required byprovider_status()and only used bygenerate_auth_url(), which returns"{client_id}||{state}"— not a URL and never called byconnect.rs. Set it, but do not expect it to affect signer creation.- Neynar header mismatch. The Rust code sends the API key as
api_key. Current Neynar OpenAPI documents the security scheme asx-api-key. Neynar has historically acceptedapi_key; if every call returns 401, change the header tox-api-keyinneynar_get()/neynar_post(). - Trailing slashes. The code uses
/v2/farcaster/signerand/v2/farcaster/signer/signed_keywithout a trailing slash. Neynar’s docs show/v2/farcaster/signer/and/v2/farcaster/signer/signed_key/. Most gateways normalize this; a 404 on signer creation is the symptom. - Signer creation is costly and not deduplicated. Every connect attempt
calls
POST /v2/farcaster/signerand registers a new signed key. There is no lookup of an existing signer before creating one. Reconnecting a channel therefore costs another approval unless you usecustomFields.signerUuid. Neynar explicitly warns to reuse signers while testing. - Approval TTL is 30 minutes and is not refreshed.
poll_farcaster()does not callput_pending()again, unlike Telegram. If the human misses the window, the state row is gone and a retry starts a new signer. create_signer()does not validate Neynar’s response. It readssigner_uuidandsigner_approval_urlwithunwrap_or_default(). A malformed/partial response produces a pending connection with an empty approval URL (and a QR code for an empty string) or an empty signer uuid that can never be polled. Check the raw Neynar response if the first connect call looks wrong.signer_status()can get stuck. It only returns an approval code whenstatus == "approved"and the response containsfid. If Neynar reportsapprovedwithoutfid(rare, e.g. partial indexing), the server keeps returning{status:"approved", code:null}and the connect poll never completes. Restarting the connection is the only recovery.- Revoked signers.
poll_farcaster()detectsstatus == "revoked", deletes the pending state and returns an actionable error. ThecustomFieldsshortcut does not special-case revoked; it says “not approved yet”. - Rate limits are plan-based. Neynar’s published tiers are roughly: Free
(limited), Starter 300 RPM / 5 RPS per endpoint with a 500 RPM global cap,
Growth 600 RPM / 10 RPS with 1,000 RPM global, Scale 1,200 RPM / 20 RPS with
2,000 RPM global.
max_concurrent_job = 3is conservative but does not reserve credits. Signer writes and active signers consume credits on top of request limits. - No signer refresh.
refresh_token()returns an empty token withexpires_in: 0; the signer uuid is the long-lived credential. If the user revokes the signer in the Farcaster app, the next cast fails with Neynar’s error and the channel must be reconnected with a new signer (or a different approvedsignerUuid). - No idempotency.
POST /v2/farcaster/castis called without Neynar’sidemfield. A retry after a timeout can double-cast. The engine’s retry policy is the only protection. - 800 characters is a Postiz limit, not a protocol limit. Farcaster casts
are limited by bytes (historically 320 bytes, now 1,024 bytes for standard
casts), not by a flat 800 characters. The engine counts
chars(); a cast of 800 emoji can exceed the byte limit, and a cast of 800 ASCII characters may be fine. There is no byte check. - No byte/embed validation. Each embed max 256 bytes and max 2 embeds are Farcaster rules; Rustiz checks neither. A long image URL or a third image fails at Neynar.
- Media is not uploaded.
embedsare URLs, so the media must be hosted publicly before publishing. This differs from Bluesky/Mastodon, where Rustiz uploads bytes. subredditsis a search, not a list. It returns at most 10 channels for the query. There is no “my channels” call. If the user does not know the channel name, they must search for it.is_web3 = true.connect.rsspecial-caseswrapcastbefore the generic web3 rejection, so it is supported. Other web3 providers are not.- No analytics / mentions.
supports_analytics,supports_post_analyticsandsupports_mentionsare allfalse; there are no overrides. - Release URL.
cast_urls()buildshttps://warpcast.com/{author username}/{cast hash}from the Neynar response. If the response omits the author username, the URL contains//; the hash is still the post id. - Multiple channels produce comma-joined ids. Any consumer that expects a
single cast hash must split on commas. The engine’s
comment()already does.
References
- Neynar dashboard: https://dev.neynar.com
- Managed signers overview: https://docs.neynar.com/docs/write-to-farcaster-with-neynar-managed-signers
- Create signer: https://docs.neynar.com/reference/create-signer
- Register signed key: https://docs.neynar.com/reference/register-signed-key
- Signer status: https://docs.neynar.com/reference/lookup-signer
- Publish cast: https://docs.neynar.com/reference/publish-cast
- Farcaster casts guide: https://docs.farcaster.xyz/developers/guides/writing/casts
- Postiz Farcaster setup: https://docs.postiz.com/self-host/providers/farcaster
- Postiz Warpcast settings: https://docs.postiz.com/public-api/providers/warpcast