A Claude-powered Discord bot with Quidli Connect integration. Ask it in plain English to look up wallets, check reputation scores, and send tokens — all without anyone needing to share a wallet address.
- Send tokens — drop USDC or other tokens to individuals or entire Discord roles via Quidli Smart Send
- Swap, then send — "swap 5 USDC to DEGEN and send 500 to @alice": the bot moves the sell amount from your Connect wallet to your Bankr wallet, swaps, brings the result back, and sends the exact amount you asked for (details)
- Market questions — token prices, contract addresses and your Bankr wallet balance, via Bankr
- Pay invoices from a PDF — drop an invoice in chat; the bot reads it and prepares the payment to the vendor's email or social handle, held until you
!confirm(details) - Multi-chain — Base by default, plus Ethereum, Optimism, Polygon, Arbitrum, Avalanche, Monad, Robinhood Chain and Solana. Explorer links follow the chain
- Look up wallets — resolve any Discord user, Farcaster handle, Twitter, email, GitHub, LinkedIn, Slack, Telegram, or phone number to their ETH/SOL wallet address
- Check reputation scores — get a composite web3 reputation score (Neynar, Lens, Ethos) for any user by social identity
- Check who you trust — reply to someone and ask "is this my co-founder?"; the bot checks your onchain trust graph by their account ID, not their display name (details)
- Send guardrail — you choose whether sends wait for
!confirm: never, always, or when you're replying to someone (details) - Check your balance — native and ERC-20 balances for your Smart Send wallet, so the bot can tell you what's short before a drop fails. Token coverage depends on Connect: on Monad and Robinhood Chain only the native balance is returned today, so a missing token there does not mean zero
- Schedule drops — send tokens at a future time, surviving bot restarts
- Presence-based drops — target only online/idle/dnd members at execution time, not when scheduled
- Conditional drops — "if BTC is above $100k, send everyone 1 USDC" — evaluated automatically using real-time web search
- Channel watchers — send tokens to the first person who types a trigger phrase
- Contributor payouts — "pay the contributors of 5000 BNKR for the last 3 days": scores merged PRs, posts the split, pays after
!confirm(owner only, details) - Cancel / reschedule — manage pending scheduled drops and watchers
- Per-user API keys — users can DM
!connect <key>to link their own Quidli account - Web search — real-time data via Brave Search for conditional drops and factual questions
- Multi-LLM support — switch between Claude, Gemini, OpenAI, OpenRouter, Hermes (Nous Portal, 200+ models), and Minds AI per channel
- Bring your own key — users DM
!llm <provider> <key>to run on their own credits - Role-based access — restrict bot usage to specific Discord roles
Discord message → LLM (Claude / Gemini / OpenAI / OpenRouter / Hermes / Minds AI)
→ Quidli Connect's MCP — lookup / exposed / scores / balance / profile / trust / drop
(tools discovered at runtime; no hand-written Connect calls remain)
→ edit Discord reply in real time
The LLM decides when to call tools based on natural language. No commands needed — just ask naturally.
Examples:
@DiscoCentaur send 1 USDC to @Guillaume
@DiscoCentaur send 0.01 USDC to everyone in @Dev
@DiscoCentaur what's the wallet for ahn.eth on Farcaster?
@DiscoCentaur what's my balance on base?
@DiscoCentaur schedule a drop of 5 USDC to @team in 2 hours
@DiscoCentaur if the USA wins tonight, send everyone 1 USDC
@DiscoCentaur send 0.01 USDC to the first person who types "gm" here today
@DiscoCentaur send 0.5 USDC to @Guillaume on Solana
@DiscoCentaur switch to gemini
@DiscoCentaur switch to claude
@DiscoCentaur switch to minds
- Node.js 22+ (uses built-in
node:sqlite) - A Discord bot application
- An Anthropic API key
- A Quidli Connect API key (from connect.quid.li)
- (Optional) Brave Search API key for web search and conditional drops
- (Optional) Gemini, OpenAI, or Nous Portal API keys for multi-LLM switching — a free Nous Portal account unlocks tool-calling models at no cost
- Go to discord.com/developers/applications and click New Application.
- Go to Bot. Copy the Token — this is your
DISCORD_TOKEN. - Under Privileged Gateway Intents, enable:
- Message Content Intent
- Server Members Intent
- Presence Intent (required for presence-based drops)
- Go to OAuth2 → URL Generator, select scope
botand permissions:- Read Messages / View Channels
- Send Messages
- Read Message History
- Open the generated URL in a browser and invite the bot to your server.
cp .env.example .env
# Fill in your values| Variable | Required | Description |
|---|---|---|
DISCORD_TOKEN |
✅ | Bot token from the Discord Developer Portal |
ANTHROPIC_API_KEY |
✅ | API key from console.anthropic.com |
QUIDLI_API_KEY |
✅ | API key from connect.quid.li |
MASTER_ENCRYPTION_KEY |
✅ | 64 hex chars (32 bytes) — encrypts stored user API keys. Generate with node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" |
BOT_OWNER_ID |
✅* | Your Discord user ID. Grants you the host Quidli wallet without !connect, and is the only account that can use the host's LLM keys by default. Leave it unset and nobody can — every user must bring their own key. |
BANKR_API_KEY |
— | Your own Bankr key, used only by BOT_OWNER_ID. Everyone else links their own with !bankr <key>. Needed for swaps and market questions; the bot runs without it, minus those (details) |
BRAVE_SEARCH_API_KEY |
— | From brave.com/search/api — required for web search and conditional drops |
CLAUDE_MODEL |
— | Defaults to claude-sonnet-4-6 |
DEFAULT_LLM_PROVIDER |
— | anthropic (default), gemini, openai, or hermes |
GEMINI_API_KEY |
— | From aistudio.google.com/apikey |
GEMINI_MODEL |
— | Defaults to gemini-2.0-flash |
OPENAI_API_KEY |
— | From platform.openai.com |
OPENAI_MODEL |
— | Defaults to gpt-4o |
NOUS_API_KEY |
— | From portal.nousresearch.com — one key, 200+ models |
NOUS_MODEL |
— | Defaults to poolside/laguna-s-2.1:free (free tier, supports tool calling) |
HOST_KEY_ALLOWED_USERS |
— | Handles/IDs allowed to spend the host's LLM keys. Empty = owner only |
HOST_KEY_ALLOWED_ROLES |
— | Discord role names allowed to spend the host's LLM keys |
DISCORD_ACTIVE_CHANNELS |
— | Comma-separated channel IDs where the bot responds to all messages (not just @mentions) |
DISCORD_ALLOWED_ROLES |
— | Comma-separated Discord role names allowed to use the bot in servers (DMs are not role-checked). Empty = everyone |
DISCORD_ALLOWED_USERS |
— | Comma-separated Discord user IDs allowed to use the bot. Empty = everyone |
SYSTEM_PROMPT |
— | Override the default system prompt |
- Log in at connect.quid.li
- Go to Smart Send and toggle it on
- Fund the Smart Send wallet with tokens and ETH for gas
npm install
npm startFor production with pm2:
pm2 start ecosystem.config.cjs
pm2 saveNote the name mismatch: the repo is discocentaur, the pm2 process and the deploy directory on
the box are both claudetaur.
npm run check # syntax check + testsnpm test prints the current count (140+ at the time of writing; don't trust a number written here). The suite covers the MCP tool gate (read-only registration, fail-closed on unannotated tools,
the legacy fallback, the keyless header), the drop recipient enum, which has silently drifted from
the server's once already, per-chain explorer links, the host-agnostic fabricated-transaction-link guard, held transfers and the send guardrail, reply context, and group trust. bot.js connects to Discord on import, so the tests extract the
functions under test from its source rather than importing it — a stopgap until it's split into
modules.
ecosystem.config.cjs sets pm2's kill_timeout to 15 s. On SIGINT/SIGTERM the bot stops taking
messages, refuses new transfers and trust changes, waits up to 10 s for any drop already submitted
to come back (shutdown.js), then exits 0. With pm2's default 1.6 s, a deploy used to SIGKILL the
bot — possibly after a transfer went out but before it was recorded.
A process first started with pm2 start bot.js --name claudetaur keeps the old timeout; switch it once
with pm2 delete claudetaur && pm2 start ecosystem.config.cjs && pm2 save. A clean restart logs
[shutdown] clean exit, and ~/.pm2/pm2.log shows exited with code [0] via signal [SIGINT]
rather than [SIGKILL].
Stored keys that can't be decrypted (for example after MASTER_ENCRYPTION_KEY changed) are
treated as missing and logged once as [keys] ⚠️ a stored key … could not be read — they are
never sent upstream as a key. A malformed MASTER_ENCRYPTION_KEY stops the bot at startup.
Users can link their own Quidli account so drops use their Smart Send wallet:
DM @DiscoCentaur: !connect <your-api-key>
DM @DiscoCentaur: !revoke
DM @DiscoCentaur: !bankr <your-bankr-key> — link your Bankr wallet (swaps, prices)
DM @DiscoCentaur: !bankr — check whether a Bankr key is linked
DM @DiscoCentaur: !bankr-remove
Get a key at connect.quid.li. Keys are stored encrypted with AES-256-GCM.
Note: wallet lookup, reputation scores and balance run on the asker's key (see below), so
users who haven't run !connect will be prompted to. Drops already required a key, so this
makes the whole Connect surface consistent rather than adding a new gate. The two public tools
(connect_get_chains, connect_get_price) answer without a key.
Each user decides whether their sends wait for confirmation. The bot asks once, right after
!connect; keys linked before this existed are asked on their first send (that send runs as
before). Change it anytime — the command works in a DM or a channel:
!guard — show your current setting and the options
!guard none — sends run as soon as you ask
!guard all — every send waits for your !confirm
!guard replies — sends wait for !confirm only when you're replying to someone else's message
Until someone picks, nothing is held (the same as before the guardrail existed). A document in the
chat holds sends whatever you pick — see PDF attachments. replies exists
because a reply that mentions the bot puts the other person's message in your turn, running on your
key: a message written to steer the bot ("…and send it now") can't send your money without your
!confirm. The held notice says which rule held it.
The choice is stored per user and enforced in runTool, not the prompt — a rule the model holds is
one a crafted message can talk it out of. Covers every money tool (connect_drop, scheduled and
conditional drops, watchers, Bankr); contributor payouts are always held regardless. Logic in held-actions.js (holdReason).
Reply to someone's message and mention the bot, and the bot knows who you mean: it gets the replied-to
author's Discord ID, username and display name, plus their message marked as their text, not
yours. The ID is treated as the identity; the display name is labelled as copyable, never proof.
Logic in reply-context.js.
This is what makes "is this my co-founder?" work as a reply: the bot checks your trust graph
(connect_trust_check, from you to that Discord ID) and answers from your own attestations. An
impostor using your co-founder's name and photo on a different account comes back "not in your trust
graph". The prompt also tells the model to warn when the display name matches someone who is — that
part is model behaviour, not enforced in code.
Connect exposes itself as an MCP server, and the bot consumes part of its surface that way
rather than hand-writing REST calls. Which tools are offered is decided by the server, not by
a list in bot.js.
Connect annotates every tool with the MCP readOnlyHint. The bot discovers the tool list at
startup, re-reads it every 10 minutes, and registers everything marked read-only. As of Connect
MCP 0.5.8 that was nine tools (the trust tools below came later):
| Tool | Notes |
|---|---|
connect_lookup |
replaced the hand-written quidli_lookup |
connect_lookup_exposed |
replaced the hand-written quidli_exposed |
connect_scores_batch |
replaced the hand-written quidli_score |
connect_scores_by_account |
single account |
connect_scores_by_username |
single Connect username |
connect_me |
the key owner's own profile — output is redacted, see below |
connect_drop_balance |
Smart Send balances |
connect_get_chains |
which chains support which features |
connect_get_price |
x402 list prices |
connect_drop is how the bot sends. It is annotated non-read-only and is offered only because it
is named in MCP_SEND_TOOLS. The model sees Connect's own description and schema, with two
changes, and the call is otherwise forwarded as written:
- The bot sets
idempotencyKey, a fresh UUID per call, and the field is hidden from the model. A model once reused the UUID spec's example key; Connect treats a reused key as the same send, so a later send could have returned an old transfer instead of making a new one. - Plain types. Connect declares some fields as unions (
tokenContractis["string","null"], the amount ananyOf);plainSchema()shows them as plainstring. A weaker model sent the USDC address as the number7.49e+47through the union type.
A 202 from Connect means recipient wallets are still being set up and nothing was sent; the bot
retries with the same key (up to 5 × 3 s). A send is held for confirmation like every money tool
while a document is in the conversation, or when the user's send guardrail says so. Scheduled/conditional drops, watchers, claims and
swap-and-send call connect_drop the same way, each with its own key. The hand-written REST
/drop client and the x402 host-wallet path are gone.
Trust tools: connect_trust_check and connect_trust_graph are read-only and register like the
rest. connect_trust_create and connect_trust_revoke are write tools — they sign an EAS
attestation from the key owner's wallet — and are offered only because they are named in
MCP_CONFIRM_TOOLS. Every call to them is held and runs only after the user's
!confirm <code>, whether or not a document is in the conversation. Any other write tool Connect
adds stays withheld until someone deliberately adds it to that set.
Trusting a group. Connect attests one identity per call, so "trust everyone in core at level 80,
context team:quidli" would otherwise mean one !confirm per person. The bot's own trust_many
tool holds the whole list (up to 25) behind one !confirm, then calls connect_trust_create
for each person in turn. Each is still its own transaction on Base, paid in ETH by your Connect wallet.
The member list comes from discord_get_role_members for a role. A failure part way through is reported per person and
the rest still run. Logic in held-actions.js (validateTrustBatch); tests in
test/pdf.test.mjs.
How it works:
selectMcpTools()inconnect-mcp.js(shared with the other bot) is the gate. It is a pure function precisely so it can be tested, and it fails closed: a tool with noreadOnlyHintis withheld. That is the case a name-pattern rule gets wrong — a futureconnect_payoutmatches/pay/, aconnect_executematches nothing at all. Absence of an annotation is unambiguous; a name is not.- A new read-only Connect tool therefore needs no code change and no restart — it appears
within 10 minutes (
MCP_REFRESH_MS), and the log says[mcp] tools updated — added: …. Removed and re-described tools are swapped the same way. This is the whole point; do not reintroduce a name list. MCP_LEGACY_ALLOWLISTis the fallback for a Connect MCP older than the annotations (< 0.5.8). If the server annotates nothing, the bot cannot tell a read from a spend, so it offers exactly the five tools it always did and logs a loud warning. Without this, pointing at an old server would register zero tools.- Discovery (
createMcpRegistry()) runs at startup and then on a timer, via plain JSON-RPC over POST — the bots never hearlistChanged, so they poll. A failed or emptytools/listkeeps the last good set, and an MCP tool whose name matches a hardcoded one is skipped, not allowed to shadow it. The server is stateless and readsx-api-keyper request, so there's no initialize handshake and no MCP SDK dependency. - Each call uses the sender's key; the host key is used only for the bot owner. A sender with
no key still gets a call:
connect_get_chainsandconnect_get_priceare served anonymously, and the rest return 401, which is the server saying a key is needed rather than a list here guessing. Thex-api-keyheader is omitted entirely when there is no key — a header the server cannot verify (including the"undefined"a missing value stringifies to) is a 401. - Only auth and quota failures become user-facing advice. Anything else still throws, so a 500 does not send someone chasing a key they already have.
connect_mereturns the caller's full profile including accounts they marked private.redactConnectMe()strips anything notexposed: truebefore the model sees it, and fails closed if the response shape changes.- If Connect is unreachable at startup the bot runs normally on the hardcoded tools. If a tool the bot has always offered stops arriving, startup logs a loud warning — some of these replace hardcoded tools, so a silent miss would quietly delete a capability.
Point it elsewhere with CONNECT_MCP_URL (defaults to https://mcp.connect.quid.li/).
Note on chains: drops are multi-chain. Base (8453) is the default and what the model uses
unless the user names another, but Ethereum (1), Optimism (10), Polygon (137), Arbitrum (42161),
Avalanche (43114) and Solana (1399811149) all work. Connect also lists Monad (143) and Robinhood Chain (4663)
as drop-capable and the bot can build explorer links for them; sends on both have worked through Connect directly
but have not been exercised through this bot. World Chain (480) is listed as drop-capable too but has no explorer link here yet. connect_get_chains lets the
model answer chain questions from the server rather than guessing; the system prompt's own chain list is
shorter than that, so the server is the source of truth.
Explorer links follow the chain. CHAIN_EXPLORERS maps a chainId to its explorer and quidliDrop() (Connect's MCP connect_drop)
attaches the right link to its own result, so no caller has to know which chain was used. A chain
absent from that map yields no link rather than a wrong one — which is safer than the previous
behaviour, where every link was hardcoded to basescan.org.
The fabricated-link guard is host-agnostic: any .../tx/<hash> URL in a model reply is stripped unless it
came from a real drop result (this turn, or a confirmed action earlier in the chat). It does not depend on
CHAIN_EXPLORERS, so an invented link on a chain with no explorer entry is still removed. It strips links
only — a plain-text "sent!" with no link is not caught.
Solana specifics the model is told about: amounts are lamports for native SOL (9 decimals), USDC is
the SPL mint EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v (6 decimals), social recipients are paid
at their solWalletAddress, and a native send to an empty wallet needs at least 890880 lamports.
Omit tokenContract (or set it null) to send a chain's native token.
To see what the server currently offers and how it is annotated:
curl -s -X POST https://mcp.connect.quid.li/ \
-H "x-api-key: $QUIDLI_API_KEY" \
-H 'content-type: application/json' \
-H 'accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' \
| python3 -c 'import sys,json;[print("%-28s readOnly=%s" % (t["name"], (t.get("annotations") or {}).get("readOnlyHint"))) for t in json.load(sys.stdin)["result"]["tools"]]'Switch the active LLM per channel at any time:
@DiscoCentaur switch to gemini
@DiscoCentaur switch to claude
@DiscoCentaur switch to openai
@DiscoCentaur switch to hermes
@DiscoCentaur switch to openrouter
Naming a model in the phrase selects it too — switch to fable, switch to opus,
switch to kimi, switch to llama, switch to deepseek, switch to mistral.
The choice persists across bot restarts (stored in SQLite). Each provider maintains its own conversation history.
The host's Anthropic / Gemini / OpenAI / Nous keys are spendable only by
BOT_OWNER_ID, anyone in HOST_KEY_ALLOWED_USERS, and members of any role in
HOST_KEY_ALLOWED_ROLES. Everybody else must connect their own key for the provider
currently in use — a Gemini key does not unlock the host's Nous key. Unauthorized
users get told to either connect a key or ask the owner for access, and they can't
switch the channel's provider either.
OpenRouter and Minds are per-user by design and never touch host credentials.
Any user can run on their own credits by DMing the bot. Keys are stored AES-256-GCM
encrypted (set MASTER_ENCRYPTION_KEY) and used instead of the host's:
!llm anthropic <key>
!llm gemini <key>
!llm openai <key>
!llm openrouter <key> [model]
!llm hermes <key> [model]
!llm-remove
switch to hermes routes the channel through Nous Portal, an
OpenAI-compatible gateway to 200+ models under one key. Set NOUS_API_KEY to give everyone the
host default (NOUS_MODEL, defaults to poolside/laguna-s-2.1:free — free tier and supports tool calling).
Users can bring their own key and pick any model:
!llm hermes <your-nous-key> anthropic/claude-sonnet-4.6
!llm hermes <your-nous-key> poolside/laguna-s-2.1:free
Model slugs are vendor-prefixed; free variants end in :free. Note that Nous's own docs advise
against the Hermes-4-* models for rapid tool-calling loops — prefer an agentic model for drops
and lookups.
switch to openrouter routes through openrouter.ai — 100+ models on one
key. There's no host-level OpenRouter key: it's per-user only, so each person DMs !llm openrouter <key> first. Default model is openai/gpt-4o.
Minds AI is a platform by Animoca Brands that lets you deploy custom AI agents trained on your own data. DiscoCentaur can route messages to your personal Mind via Discord.
How to connect your Mind:
- Get a Builder API key at build.hellominds.ai/console (sign in → Keys → Create)
- DM the bot:
If you have more than one Mind, specify which one:
!minds <builder-api-key>!minds <builder-api-key> <mind-name> - Switch the channel to Minds mode:
@DiscoCentaur switch to minds
Per-user privacy: Each user connects their own Builder API key. The bot uses it to call the Minds API on their behalf — no one else can access your Mind, and the bot owner's Mind is never shared. Keys are stored encrypted with AES-256-GCM.
Other DM commands:
!minds-remove — disconnect your Minds credentials
⚠️ Experimental. The Minds API is in active development and can be intermittently unreliable — requests sometimes time out on their side with no error. If your Mind stops responding, wait a few minutes and try again. For anything critical, switch to Claude.
Performance depends on the model. Claude delivers the best results and is the recommended provider for all actions. It was purpose-built for agentic tool use and handles the full feature set reliably — drops, role-based sends, scheduling, conditional drops, presence filters, and multi-step lookups.
Gemini and OpenAI are included as options but have significant limitations in practice: they tend to narrate what they would do instead of executing tools, hallucinate results, and fail on multi-step chains. Use them for casual conversation only. For anything involving tokens or scheduling, switch to Claude.
@DiscoCentaur send 1 USDC to @team in 3 hours
@DiscoCentaur send 0.01 USDC to everyone online tomorrow at 9am
@DiscoCentaur if ETH hits $5000 today, send 0.5 USDC to @holders
@DiscoCentaur list my scheduled drops
@DiscoCentaur cancel drop <id>
Scheduled drops survive bot restarts. Conditional drops use Brave Search to evaluate the condition at the scheduled check time.
@DiscoCentaur send 0.01 USDC to the first person who types "gm" here
@DiscoCentaur give 1 USDC to the first 3 people who say "wagmi" in this channel
@DiscoCentaur list my watchers
@DiscoCentaur cancel watcher <id>
@DiscoCentaur pay the contributors of https://github.com/owner/repo 5000 BNKR for the last 3 days
Rewards the authors of a repo's merged pull requests from a budget you name. It runs the
contributor-payout CLI as a child process,
so that repo must be checked out next to this one (or at PAYOUT_DIR) with its own .env.
- Propose. One
payouttool call scores the PRs in the window (--payer connect --skip-resolve: a dry run — nothing is signed and nobody gets a wallet provisioned) and saves the split as a round file,rounds/<label>.json. Takes 60–120 s. - The bot posts the split itself, read from the round file. The model never sees the amounts, because a model that retyped them once got two rows wrong while the total still matched.
- Confirm. The payment is always held — document or not — and runs only after the owner's
!confirm <code>. Execution reads everything (repo, token, every amount) from the round file, pays from the agent's Dynamic wallet and comments on the PRs.
Guards:
- Owner only (
BOT_OWNER_ID); anyone else is refused. - Every parameter must come from the message. The repo name, the budget and the window ("3d", "3 days", "a week" …) must appear in what the user wrote, or nothing runs — a window the model filled in on its own silently pays a different set of people.
- One proposal per repo for 15 minutes (
PAYOUT_REPROPOSE_TTL_MS): asking again returns the same round instead of a new, different split. A round already marked paid can't be paid again. - Paid means broadcast. Exit code 0 isn't success: a run counts as paid only if it printed
Sent.and transaction hashes. Otherwise the bot says why nothing was paid. - A run that times out (
PAYOUT_EXEC_TIMEOUT_MS, 10 min) is reported as unknown — check the round's ledger and Basescan before re-running. - Shares the Bankr rate limit per user.
| Variable | Default | |
|---|---|---|
PAYOUT_DIR |
../contributor-payout |
where the payout CLI lives |
PAYOUT_TIMEOUT_MS |
180000 | proposal time limit |
PAYOUT_EXEC_TIMEOUT_MS |
600000 | execution time limit |
PAYOUT_REPROPOSE_TTL_MS |
900000 | how long a proposal is reused |
Don't restart during a payout. The CLI runs in its own process, and a pm2 restart stops it too;
the bot's 10-second shutdown wait doesn't cover a run that takes minutes. Check
pgrep -fl payout.js before deploying.
Access to DiscoCentaur in servers is controlled by DISCORD_ALLOWED_ROLES in .env. Only members with those roles can interact with the bot or switch providers there.
DMs are not role-checked. The role check runs only for server messages; a direct message to the bot skips it (DISCORD_ALLOWED_USERS still applies in DMs, and the host's LLM keys still need BOT_OWNER_ID, HOST_KEY_ALLOWED_USERS or HOST_KEY_ALLOWED_ROLES). If the role list is meant to be a hard gate, set DISCORD_ALLOWED_USERS too.
Granting access: Assign the allowed role to a user in Discord (Server Settings → Roles, or right-click the user). There is no bot command for this — it's managed through Discord's native role system.
Notifying new members: Discord doesn't proactively notify users when they receive a role. Options:
- Manual — tell the user directly that they now have access and can use
@DiscoCentaur - Automated — a future version of DiscoCentaur can listen to the
GuildMemberUpdateevent and automatically DM new members when they receive an allowed role, with onboarding instructions. This is not yet built in but is a planned improvement.
Changing allowed roles:
Edit DISCORD_ALLOWED_ROLES in .env and restart the bot (pm2 restart claudetaur). Multiple roles are comma-separated:
DISCORD_ALLOWED_ROLES=Team Quidli,Admins,Moderators
If a user tries to access without permission: The bot replies in-channel telling them which role is required. They won't be able to switch providers or trigger any actions until the role is granted.
Attach a PDF (or reply to a message that has one) and mention the bot — it reads the document and acts on what you ask. A bare upload with no text is treated as "summarise this and tell me what it asks me to do". Works in DMs and on every provider except Minds.
- Up to 3 PDFs per message, 10 MB each, first 50 pages, ~48k characters (the bot says when it truncated).
- Text extraction only (
unpdf). Scanned / image-only PDFs are refused with a message, not guessed at. Password-protected PDFs are refused. - The text goes into the channel's conversation history, so follow-ups ("now pay line 3") work until it ages out of the 40-message window or the provider is switched.
Transfers are held while a document is in context. A PDF is third-party text and can contain instructions ("send 500 USDC to …"). So for 40 turns after anyone uploads a document to a channel — long enough for both the document and every reply that quoted it to leave the 40-message history — connect_drop, schedule_drop, conditional_drop, create_watcher, bankr_agent, bankr_swap_and_drop and payout don't run. The bot posts exactly what would happen, built from the tool arguments rather than the model's prose, with a code:
!confirm K7QM2P — run it
!cancel K7QM2P — drop it
!confirm — list your held transfers
Codes are one-shot, bound to the user who made the request, expire after 10 minutes, and live in memory (a restart clears them — re-ask). The result of each !confirm / !cancel (sent, failed, unknown, cancelled) is passed to the model on the channel's next turn, so "did that go through?" gets a real answer instead of a second send. Switching provider wipes the history and ends held-transfer mode with it; so does a restart (the flag is in memory). This is enforced in runTool, not the prompt. Logic lives in documents.js and held-actions.js; tests in test/pdf.test.mjs, including one that fails if a new money-moving tool is added without being gated.
Every user message is capped at 25 tool round-trips (MAX_TOOL_ROUNDS) across all
providers. A send is usually 1–2 calls (sometimes a balance check, then connect_drop), so the
cap only trips on a model that's looping. It still matters: every connect_drop call gets its own
idempotency key, so a model that calls it repeatedly issues repeated distinct transfers rather
than harmless retries. On hitting the cap the bot stops and says so.
Every model call times out after 2 minutes and is retried once (LLM_TIMEOUT), so a stalled
provider shows an error instead of "Thinking…" for up to half an hour (the SDK defaults).
Two related guards on the OpenAI-compatible path (OpenAI, OpenRouter, Hermes):
- Tool calls are executed whenever present, regardless of
finish_reason— some providers label themstop, and requiringtool_callswould silently skip execution - Except when
finish_reasonislength: a response cut off by the token limit may carry half-written JSON arguments, so it's rejected rather than run - Arguments that fail to parse return an error to the model instead of calling the tool
with
{}
"Stopped after 25 tool steps": The model looped without finishing. Retry with a simpler request, or switch providers — nothing after the cap was executed.
Bot doesn't respond: Check that Message Content Intent, Server Members Intent, and Presence Intent are all enabled in the Discord Developer Portal.
Role lookups return wrong members: Use the Discord role picker (type @ and select from the dropdown) rather than typing the role name as plain text.
Drop returns 400: Ensure Smart Send is enabled and funded at connect.quid.li.
Lookup, score or balance says a key is needed: the person asking must link their own key with !connect <key> in a DM. The host key (QUIDLI_API_KEY) only covers BOT_OWNER_ID. If nobody gets Connect tools, check QUIDLI_API_KEY is set in .env: the bot registers Connect's MCP tools only when it has a host key.
Conditional drop fires at wrong time: Make sure BRAVE_SEARCH_API_KEY is set — without it the bot can't look up event schedules and will guess the check time.
Gemini not calling tools: This is a known limitation of Gemini Flash/Pro for multi-step tool use. Switch back to Claude for any action that involves drops, lookups, or scheduling.
Connect resolves recipients itself. connect_drop takes social recipients (email, phone,
Telegram, Discord, Farcaster, X, GitHub — by numeric id or username) and pays their Ethereum
address on EVM chains or their Solana address on Solana, creating a wallet for people who don't
have one yet. linkedin and slack aren't accepted by connect_drop: the model looks those up with
connect_lookup and sends to the returned address as { "type": "wallet", "id": … }. The bot
does not look anyone up before a send.
Mentions reach the model as the recipient connect_drop takes —
@Guillaume = {"type":"discord","id":"731076204307677226"} — so it sends by ID. A Discord
display name is not an identifier and Connect cannot resolve it.
recipients.js (EVM wallet resolution via connect_lookup) is used only by Bankr
swap-and-send, which needs wallet addresses before it starts.
Connect has no swap of its own, so swaps run in the user's Bankr wallet
and the proceeds come back to Connect. Bankr is reached over its HTTP API from bankr.js; no
Bankr SDK or package is installed. Two tools: bankr_swap_and_drop (the swap flow, on Bankr's
Wallet API) and bankr_agent (prices, contract addresses, Bankr balance, on Bankr's Agent API).
Without a linked key, both tools refuse and nothing else about the bot changes.
- Each user links their own key: DM
!bankr <key>(!bankralone reports whether one is linked) (create it at bankr.bot/api with Agent API on, Read Only off).!bankr-removeunlinks. Keys are stored encrypted like Quidli keys. - The owner can set
BANKR_API_KEYin.envas a host key; onlyBOT_OWNER_IDuses it. - Paying people still goes through Quidli Connect by default — Bankr can only pay existing Bankr users.
bankr_swap_and_drophandles every swap. By default it sends the sell amount from the user's Connect Smart Send wallet to their Bankr wallet, swaps there (Wallet API, not the chat agent), sends exactly the received amount back to Connect, then optionally sends recipients an exactamountPerRecipient(existing Connect balance counts; the rest stays in Connect) — or everything, only with an explicitsendAll. A fixed amount the swap can't cover is refused before anything moves.source: "bankr"swaps funds already in Bankr. It stops before moving anything if the Bankr wallet has no gas (MIN_BANKR_GASinbankr.js) and tells the user how much to add. Needs both keys (!connectfor Quidli,!bankrfor Bankr); the Bankr key needs Wallet API enabled. Any failed or uncertain step stops the chain and reports where the funds are; nothing is retried automatically.bankr_agentis a money tool: held for confirm while a document is in context, capped at 8 calls per user per 10 min, and a job still running after 2 min is reported as "may still execute", never resubmitted.- Explorer links are shown only when Bankr's own response contained them.
bankr.js, recipients.js, connect-mcp.js, secrets.js, shutdown.js and their tests must be identical in TeleCentaur and DiscoCentaur (list: scripts/shared-files.mjs). npm test fails if they drift, when the other repo sits next to this one (or SHARED_SIBLING=/path). Edit in one repo, then run npm run sync-shared there to copy the files over, review git diff in the other repo, and commit both.