PHPackages                             ekumanov/flarum-ext-claude-reply - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. ekumanov/flarum-ext-claude-reply

ActiveFlarum-extension

ekumanov/flarum-ext-claude-reply
================================

Mention a designated bot account in a Flarum 2.0 discussion and Claude replies in-thread. Queue-backed, token-budgeted discussion context, allowlisted triggers, hard spend caps.

v1.2.0(today)17↑50%MITPHPPHP ^8.2

Since Aug 16Pushed todayCompare

[ Source](https://github.com/ekumanov/flarum-ext-claude-reply)[ Packagist](https://packagist.org/packages/ekumanov/flarum-ext-claude-reply)[ RSS](/packages/ekumanov-flarum-ext-claude-reply/feed)WikiDiscussions main Synced today

READMEChangelog (3)Dependencies (6)Versions (8)Used By (0)

Claude Reply
============

[](#claude-reply)

Mention a designated bot account in a Flarum 2.0 discussion and Claude replies in-thread, as that account.

Built to be kept on a short leash: only explicitly allowed members can trigger a reply, only in explicitly allowed tags, never in private discussions, under both a per-member and a forum-wide daily cap.

How it works
------------

[](#how-it-works)

1. An allow-listed member mentions the bot account (`@claude_user` by default) in a post.
2. A listener runs inside the post-save request, checks the gates, writes an audit row, and queues a job. No network I/O happens in the request.
3. The worker assembles a token-budgeted excerpt of the discussion, makes **one** Messages API call, and posts the reply through Flarum's own API layer — so mentions resolve, notifications fire, realtime pushes it, and any purge-on-write hooks run exactly as for a human post.

There is no session state. Claude's own replies are ordinary posts, so the next time it is mentioned in the same thread it reads its earlier answers back as context.

Requirements
------------

[](#requirements)

- Flarum **2.0**, `flarum/mentions` and `flarum/tags` enabled
- **A real queue driver** (`redis`, `database`, …) and a running worker. The extension refuses to run on the `sync` queue rather than block a member's post-save request for minutes — see Troubleshooting.
- An Anthropic API key ([console.anthropic.com](https://console.anthropic.com)). This is billed per token, separately from any Claude Pro/Max subscription.

Setup
-----

[](#setup)

**1. Bot account.** Create a normal user for Claude to post as. It needs `reply` permission in the allowed tags, and must **not** be in a group whose posts require approval — otherwise replies land in the approval queue and never appear.

**2. API key.** Either paste it into the admin field, or — better — put it in `config.php`:

```
'claude_reply' => [
    'api_key' => 'sk-ant-...',
],
```

Resolution order is `config.php` → `ANTHROPIC_API_KEY` in the environment → the settings table, and the first one found wins. The admin field exists because not every install has shell access, and it is handled carefully: the value is masked out of the admin page payload, so it is never sent to a browser, and saving the page without touching the field cannot overwrite it. It is still stored in the settings table though, which means it is in your database backups and your settings cache — which `config.php` values are not. Prefer `config.php` where you can.

**3. Settings** (Admin → Extensions → Claude Reply). The gate is fail-closed and does nothing until you configure it:

SettingDefaultNotesEnable Claude replies**off**Master switchAllowed members / groups**empty = nobody**Pick from a search boxAllowed tags**empty = no tag**Pick from your tag listEnabling the extension alone cannot send a single post to Anthropic; you have to name who and where.

Others worth tuning: `model` (a dropdown of the models your key can actually call), `effort` (default `medium`), `context_token_budget` (20000), `per_user_daily_limit` (2), `daily_reply_limit` (25), `persona_prompt`, `footer`.

Who may summon a reply
----------------------

[](#who-may-summon-a-reply)

Six lists, in three pairs — members, groups, tags — each with an allow list and a deny list. They resolve in a fixed order, and the first match decides:

1. denied members → **no**
2. allowed members → **yes**
3. denied groups → **no**
4. allowed groups → **yes**
5. nothing matched → **no**

Two rules fall out of that, and they are the ones to remember. **Deny beats allow within a level**, so listing someone in both is a deny — which makes a deny list safe to reach for in a hurry. And **a person beats their groups**, so a member of an allowed group can be denied individually, and a member of a denied group can be allowed individually.

**An empty members list does not mean nobody.** It means that level had nothing to say and the question passes to the groups, which is why allowing one group works perfectly well with both member lists empty. Only step 5 — matched by nothing at either level — is a refusal.

**Administrators are allowed by default**, skipping the lists entirely. Gating someone who can rewrite these settings achieves nothing, and being quietly excluded from a feature you administer is a confusing way to find out. There is a switch if you disagree.

### Blocklist mode

[](#blocklist-mode)

Each of the two questions — who, and where — has a switch turning it from an allow-list into a blocklist: *everyone except those denied*. It changes exactly one thing, the final step, so every precedence rule above still holds.

It is an explicit setting rather than something inferred from a list being empty. Inferring it was considered and rejected: the same empty field would mean "nobody" or "everybody" depending on a neighbouring field, and it would fail *open* on a config edit — adding one denied tag would silently expose every other tag on the forum.

Tags work like members, minus the hierarchy: one denied tag on a discussion refuses it even if another of its tags is allowed. Parent tags need no special rule — Flarum attaches the parent whenever a child is selected, so allowing or denying a parent covers its children through the data.

Note that admins bypass the *member* lists by default but **not** the tag lists, which default to no bypass. The tag lists are not about who is trusted; they decide which of your members' content may leave the forum, and an admin absent-mindedly mentioning the bot in a private-ish tag is the accident they exist to prevent. Private discussions are refused for everybody, always.

Limits
------

[](#limits)

Two caps, both over a rolling 24 hours, both counted from the audit table so a restart or a deploy cannot hand out a fresh allowance:

- **Per member** (default 2). A fairness device, so one person cannot spend the day's replies alone. Groups granted **Bypass the per-member Claude reply limit** in Permissions are exempt; admins are exempt implicitly, as they are from every permission. Set to 0 to disable it entirely.
- **Forum-wide** (default 25). The actual spend ceiling. This one is not bypassable by anybody, which is the point of it. Set to 0 to block all replies.

When a member is out of quota, the composer tells them **before** they post — "mentioning @claude\_user in this post will not produce an answer" — with the choice to post anyway or go back and edit. Nothing is generated and nothing is billed either way; the alternative designs both cost something for no benefit, since having the bot post "you are out of replies" spends an API call to say that no more API calls are available.

The warning is advisory. The server enforces the cap regardless, and records a `skipped` audit row so a refusal is never invisible.

Quoting and replying
--------------------

[](#quoting-and-replying)

Claude is given each post's real Flarum mention token in the context it reads (`@"Name"#p123`), and uses them to quote and reply the way the Reply and Quote buttons do — a real blockquote, a real reply pointer, not a plain-text paraphrase.

Note the reverse does not hold by default: Flarum's Reply arrow and Quote button insert a link to a *post*, not a mention of an *account*, so quoting the bot does not summon it. Only an @-mention does. There is a setting to accept quotes as well, off by default — in a thread about the bot, members quote it to talk about it, and it would join every one of those conversations uninvited.

Anything it emits that points at a post or a person it was **not** shown is stripped before publishing, leaving the bare name behind. A wrong post id would only render as visible junk, but a wrong *user* id notifies a real member who had nothing to do with the discussion, and a group mention notifies everyone in the group. Only the ids that were in the context survive; flarum/mentions rewrites the display name from the id on parse, so a stale nickname corrects itself.

Tuning without spending money
-----------------------------

[](#tuning-without-spending-money)

```
php flarum claude-reply:test
```

Prints a gate report — every check, its verdict and the reason, for the post's author against current settings — then the exact context that would be sent, the post count, the heuristic estimate and the real `count_tokens` figure. Add `--send` to also call the API and print the reply, still without posting it, or `--gate` to stop after the gate report.

This is the first thing to reach for when the answer to "why didn't the bot reply to that?" is not obvious.

Use this to iterate on `persona_prompt` and `context_token_budget` before letting it near a real thread.

Context strategy
----------------

[](#context-strategy)

Whole-thread-always is wrong: expensive on long threads, and the middle is mostly noise. Instead:

- The **opening post is always included** — it frames the thread.
- Posts the trigger **quotes or post-mentions** are force-included wherever they sit.
- Everything else is a greedy walk backwards from the mention until the token budget or post cap is hit.
- Holes are marked `[… N earlier post(s) omitted …]`, which is what stops the model inventing continuity across the gap.

Posts are sent as their **unparsed source** (the markdown the author typed), not rendered HTML — cheaper and more faithful.

Privacy
-------

[](#privacy)

Mentioning the bot sends that discussion's posts — other members' words, under their real display names — to Anthropic. That is the deal; the allow-lists exist so you opt into it deliberately.

- Private discussions (fof/byobu) are **always refused**, hard-coded, not a setting.
- Hidden and pending-approval posts are excluded (the builder uses Flarum's own `comments()` scope).
- Check your Anthropic org's data-retention setting before going live.

An AI-disclosure `footer` is configurable and recommended.

Cost
----

[](#cost)

One reply ≈ one Messages API call. On `claude-opus-5` ($5/$25 per MTok), a short thread costs roughly $0.05 and a full 20k-token context roughly $0.14. Every call's token counts are written to `ekumanov_claude_replies`, so spend can be reconciled against the console:

```
SELECT DATE(created_at) d, COUNT(*) replies,
       SUM(input_tokens) tok_in, SUM(output_tokens) tok_out
FROM ekumanov_claude_replies WHERE status='posted' GROUP BY d ORDER BY d DESC;
```

Prompt caching is deliberately **not** used: mentions arrive minutes or hours apart while the cache TTL is five minutes, so cache writes (1.25×) would almost never be read back (0.1×).

Troubleshooting
---------------

[](#troubleshooting)

SymptomCause`refusing to run on the sync queue` in the logSet a real queue driver and run a worker. Running a multi-minute API call inline would hang the composer.Nothing happens, nothing loggedRun `claude-reply:test  --gate` — it names the gate that refused.A member stopped getting repliesTheir per-member cap. `skipped` rows in the audit table record it, with `per_user_daily_limit_reached` in `error`.`no API key configured``config.php` key missing or the file wasn't reloaded.Audit row stuck `pending`The worker never picked the job up.Audit row `failed`Read its `error` column — it holds the API error verbatim (truncated to 255 chars).Reply cut off mid-sentence`max_tokens` too low. It caps thinking **and** visible text together.Implementation notes
--------------------

[](#implementation-notes)

Non-obvious Flarum 2.0 details this depends on, all verified against rc.5:

- **`$post->content` is not the XML.** The `HasFormattedContent` accessor unparses on read; the stored XML is **`$post->parsed_content`**. Mention extraction has to use the latter.
- **`Flarum\Post\Command\PostReply` no longer exists.** Internal post creation goes through `JsonApi::process()` against `PostResource` with the `create` endpoint.
- **`Extend\Event::listen()` will not take `[Class::class, 'method']`** for a non-static method, whatever its docblock says — PHP's `callable` type rejects it. A class handling two events has to be registered with `->subscribe()`.
- **The admin page dumps the whole settings table into the page HTML** (`AdminPayload`). A secret stored as a setting is readable in the DOM by any admin and any admin-side extension JS, unless it is masked in the `Deserializing` event first.
- **Initializers run before `app.forum` exists.** Core's `boot()` calls initializers, *then* assigns `app.forum` from the store, so reading the payload at the top of an initializer throws on every page load.
- **`state` is a reserved attr name for modals.** `ModalManager` passes its own `state` down to the modal component, silently clobbering an attr of that name.
- **Don't name a job method `fail()`.** `AbstractJob` inherits a public `fail()` from Laravel's `InteractsWithQueue`; redeclaring it privately is a class-load fatal, which no `try/catch` can see — the symptom is a worker that dies silently mid-listener.

Licence
-------

[](#licence)

MIT

###  Health Score

42

—

FairBetter than 88% of packages

Maintenance100

Actively maintained with recent releases

Popularity6

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity48

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~0 days

Total

7

Last Release

0d ago

### Community

Maintainers

![](https://www.gravatar.com/avatar/a23c4539c1c2283960743eb8b92e2c56fb2c5f33a7b44bd03a3d07b882fd67ed?d=identicon)[ekumanov](/maintainers/ekumanov)

---

Top Contributors

[![ekumanov](https://avatars.githubusercontent.com/u/24654623?v=4)](https://github.com/ekumanov "ekumanov (7 commits)")

---

Tags

aiextensionclaudeflarumanthropic

### Embed Badge

![Health badge](/badges/ekumanov-flarum-ext-claude-reply/health.svg)

```
[![Health](https://phpackages.com/badges/ekumanov-flarum-ext-claude-reply/health.svg)](https://phpackages.com/packages/ekumanov-flarum-ext-claude-reply)
```

###  Alternatives

[flarum-lang/russian

Russian language pack for Flarum.

13129.1k](/packages/flarum-lang-russian)[flarum-lang/french

French language pack to localize the Flarum forum software plus its official and third-party extensions.

1941.0k](/packages/flarum-lang-french)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
