Skip to content

About

Multiplayer Discord bot on Quidli Connect. Everyone runs on their own keys and wallet — send tokens to people or whole roles, look up wallets, check reputation and balances in plain English.

Resources

Stars

3 stars

Watchers

0 watching

Forks

Repository files navigation

DiscoCentaur

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.

What it can do

  • 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

How it works

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

Prerequisites

  • 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

Setup

1. Create a Discord bot

  1. Go to discord.com/developers/applications and click New Application.
  2. Go to Bot. Copy the Token — this is your DISCORD_TOKEN.
  3. Under Privileged Gateway Intents, enable:
    • Message Content Intent
    • Server Members Intent
    • Presence Intent (required for presence-based drops)
  4. Go to OAuth2 → URL Generator, select scope bot and permissions:
    • Read Messages / View Channels
    • Send Messages
    • Read Message History
  5. Open the generated URL in a browser and invite the bot to your server.

2. Configure environment variables

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

3. Enable Smart Send (for token drops)

  1. Log in at connect.quid.li
  2. Go to Smart Send and toggle it on
  3. Fund the Smart Send wallet with tokens and ETH for gas

4. Install and run

npm install
npm start

For production with pm2:

pm2 start ecosystem.config.cjs
pm2 save

Note the name mismatch: the repo is discocentaur, the pm2 process and the deploy directory on the box are both claudetaur.

5. Before you push

npm run check     # syntax check + tests

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

Restarts and shutdown

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.

Per-user API keys

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.

Send guardrail

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

Replying to a message

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.

Quidli Connect over MCP

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 (tokenContract is ["string","null"], the amount an anyOf); plainSchema() shows them as plain string. A weaker model sent the USDC address as the number 7.49e+47 through 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() in connect-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 no readOnlyHint is withheld. That is the case a name-pattern rule gets wrong — a future connect_payout matches /pay/, a connect_execute matches 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_ALLOWLIST is 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 hear listChanged, so they poll. A failed or empty tools/list keeps 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 reads x-api-key per 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_chains and connect_get_price are served anonymously, and the rest return 401, which is the server saying a key is needed rather than a list here guessing. The x-api-key header 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_me returns the caller's full profile including accounts they marked private. redactConnectMe() strips anything not exposed: true before 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"]]'

Multi-LLM switching

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.

Who can use the host's keys

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.

Bring your own key

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

Hermes / Nous Portal

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.

OpenRouter

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 (experimental)

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:

  1. Get a Builder API key at build.hellominds.ai/console (sign in → Keys → Create)
  2. DM the bot:
    !minds <builder-api-key>
    
    If you have more than one Mind, specify which one:
    !minds <builder-api-key> <mind-name>
    
  3. 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.

Scheduled & conditional drops

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

Channel watchers

@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>

Contributor payouts

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

  1. Propose. One payout tool 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.
  2. 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.
  3. 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.

Role management

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 GuildMemberUpdate event 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.

PDF attachments

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.

Tool-loop safety

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 them stop, and requiring tool_calls would silently skip execution
  • Except when finish_reason is length: 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 {}

Troubleshooting

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

How recipients are resolved

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.

Bankr — swaps and market data

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> (!bankr alone reports whether one is linked) (create it at bankr.bot/api with Agent API on, Read Only off). !bankr-remove unlinks. Keys are stored encrypted like Quidli keys.
  • The owner can set BANKR_API_KEY in .env as a host key; only BOT_OWNER_ID uses it.
  • Paying people still goes through Quidli Connect by default — Bankr can only pay existing Bankr users.
  • bankr_swap_and_drop handles 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 exact amountPerRecipient (existing Connect balance counts; the rest stays in Connect) — or everything, only with an explicit sendAll. 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_GAS in bankr.js) and tells the user how much to add. Needs both keys (!connect for Quidli, !bankr for 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_agent is 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.

Shared files

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.

About

Multiplayer Discord bot on Quidli Connect. Everyone runs on their own keys and wallet — send tokens to people or whole roles, look up wallets, check reputation and balances in plain English.

Resources

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages