Claude connector - allow Claude to post and reply via API, etc.

NealC

Well-known member
Licensed customer
AI is here, many of us are using it day and day out. I'm a developer and Claude code is my main developer. Amazing era we are in. I allow Claude to post and reply to Atlassian Jira and Confluence (our wiki) discussing with staff and testers on cases, features, suggestions, etc. The next step is going to be my forum when we open to beta testing allowing Claude to read and potentially respond.

I can probably do this like with Atlassian but I'm not sure if XF would want to create something more seamless and integrated with a plugin, connector or whatever the right answer may be. I can certainly create an account and let it use my browser with claude-in-chrome, there are options, but this is an opportunity for XF to integrate with AI tools that are becoming more and more the way we operate now and in the future.
 
Upvote 1
Create an account on your board that Claude can use, then create a user API key with thread:read, thread:write, node:read, and node:write permissions, then tell Claude Code to create an MCP, pointing it to the REST API documentation, and your API key.

Everything you need is already there, but if none of this makes sense, Claude Code will understand. You can then tell it to
Markdown (GitHub flavored):
Retrieve the names of our newest members on the board, 
and, using the XenForo-MCP, generate and publish a post in the 
General -> New User Introductions forum to welcome each one.
 
Works well for the most part. I added additional permissions based on my use case. It seems though Claude via API can't add thread prefixes such as I prefer "Announcement" for our release announcement and notes.
 
The API can set prefixes, but it silently drops one the Claude account isn't allowed to use, and it reads a prefix name as 0. Send the prefix's numeric ID as prefix_id (it's the number in the prefix's Admin CP edit URL, e.g. thread-prefixes/announcement.5/edit), and make sure the account can use "Announcement": Admin CP > Forums > Thread properties > Thread prefixes, edit it, and under "Usable by user groups" tick a group the account is in, other than Registered (every member is in that one). Details below, plus a helper that fails loudly.

It works on both create (POST threads/) and edit (POST threads/{id}/). The API docs for prefix_id say: "ID of the prefix to apply to the thread. If not valid in the selected forum, will be ignored." That "ignored" is the key part. When creating, the prefix is only applied if the API user can use it:

PHP:
// XF/Api/Controller/ThreadsController.php
if ($input['prefix_id'] && ($isBypassingPermissions || $forum->isPrefixUsable($input['prefix_id'])))
{
    $creator->setPrefix($input['prefix_id']);
}
($isBypassingPermissions is only true for a super user key sent with api_bypass_permissions=1, which bypasses the user's permissions in general, so I wouldn't hand that to an AI account. With a user key like the one from post #2 it's always false.)

Why a prefix gets ignored
  1. prefix_id is the name instead of the numeric ID. It's read as 'prefix_id' => 'uint', so "Announcement" becomes 0.
  2. None of the Claude account's user groups is allowed to use that prefix.
  3. The prefix isn't enabled for that forum (or the ID doesn't exist).
  4. Your MCP's create tool doesn't send prefix_id at all, or sends it under another name: fields the API doesn't know are ignored too. The endpoint pages on docs.xenforo.com build their parameter lists with JavaScript, so a plain fetch of them shows none; the full spec is at https://docs.xenforo.com/api/openapi.json if you want to hand Claude Code something it can read.

What you get back when creating
  • Forum doesn't require a prefix: 200, the thread is created without a prefix, no error.
  • Forum requires a prefix: 400 with error code please_select_a_prefix, and nothing is created. The exception is an account that can't use any of that forum's prefixes: then the thread is created without one.
So it's worth having your MCP check prefix_id in the response.

Finding the ID and fixing access
GET forums/{id}/ lists the forum's prefixes under forum.type_data.prefixes, each with prefix_id, title and is_usable (whether this account can select it; see the ThreadPrefix schema). The docs don't say the forum response carries that list, since type_data is only described as varying by node type. Titles come back HTML-escaped, so "Q&A" arrives as Q&A.
  • is_usable: false: edit the prefix (Admin CP > Forums > Thread properties > Thread prefixes) and tick one of the account's groups under "Usable by user groups", but not Registered: every member is in it, so all of them could use "Announcement". If Registered is the account's only group, create one for it (Admin CP > Groups & permissions > User groups > Add user group), add the account to it under "Secondary user groups" on its user page, and tick that one. A secondary group is enough.
  • Not listed at all: it isn't enabled for that forum. Edit the forum (Admin CP > Forums > Nodes) and tick "Announcement" under "Available prefixes". (The prefix's own "Applicable forums" box is a multi-select, where a plain click deselects every other forum.)

Edits have their own rules
PHP:
// XF/Api/Controller/ThreadController.php
if (isset($input['prefix_id']) && ($isBypassingPermissions || $thread->isPrefixEditable()))
{
    $prefixId = $input['prefix_id'];
    if ($prefixId != $thread->prefix_id
        && $isCheckingPermissions
        && !$thread->Forum->isPrefixUsable($input['prefix_id'])
    )
    {
        $prefixId = 0; // not usable, just blank it out
    }
    $editor->setPrefix($prefixId);
}
  • Leave prefix_id out to keep the current prefix. Sending 0, or the name (read as 0), removes it.
  • If the thread has no prefix, or one the account can use, and you ask for one it can't use, you get 200 and the thread ends up with no prefix: the same silent drop as on create, so check prefix_id in the edit response too.
  • In a forum that requires a prefix, an edit that would leave the thread without one is refused with please_select_a_prefix and the thread is unchanged, unless the account can move the thread (e.g. a moderator) or can't use any of that forum's prefixes, in which case it goes through without one.
  • If the thread's current prefix is enabled in that forum but the account can't use it (say staff set it), isPrefixEditable() is false and prefix_id is ignored entirely: 200, the prefix stays, and editing the thread can't change or remove it. To change that, allow one of the account's groups on that prefix, as above.

Optional: a helper that fails loudly
If your MCP creates the threads, this makes the silent drop impossible to miss. It finds the prefix by its title (ignoring case and the HTML escaping), checks is_usable before creating anything, and then confirms the prefix actually landed.
JavaScript:
// Create a thread with a prefix chosen by its title, and fail loudly instead of silently.
// For a user API key (XF-Api-Key header). Uses the built-in fetch (tested on Node 20).
// baseUrl is your board's API root, e.g. https://example.com/api. Take baseUrl and apiKey from your
// server's own config, never from tool input, so the model can't choose where the key is sent.
const ENTITIES = { '&amp;': '&', '&lt;': '<', '&gt;': '>', '&quot;': '"', '&#039;': "'" };
// the API sends prefix titles HTML-escaped ("Q&A" arrives as "Q&amp;A"); compare them as plain text
const plain = (s) => String(s).replace(/&(?:amp|lt|gt|quot|#039);/g, (e) => ENTITIES[e]).trim().toLowerCase();
const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]']);

export async function createThreadWithPrefix({ baseUrl, apiKey, nodeId, prefixTitle, title, message, timeoutMs = 15000 }) {
    const idText = typeof nodeId === 'number' || typeof nodeId === 'string' ? String(nodeId).trim() : '';
    if (!/^[1-9]\d{0,9}$/.test(idText)) throw new Error(`Invalid forum id: ${nodeId}`); // 2 or "2", nothing else
    const forumId = Number(idText);
    for (const [name, value] of Object.entries({ prefixTitle, title, message })) {
        if (typeof value !== 'string' || !value.trim()) throw new Error(`Missing ${name}`);
    }
    const key = typeof apiKey === 'string' ? apiKey.trim() : ''; // a key read from a file often ends in a newline
    if (!/^[\x21-\x7e]+$/.test(key)) throw new Error('Invalid apiKey: check your config'); // never echo the key
    const root = String(baseUrl).replace(/\/+$/, '');
    let rootUrl;
    try { rootUrl = new URL(root); } catch { throw new Error('Invalid baseUrl: check your config'); } // it may hold a password
    if (rootUrl.username || rootUrl.password) throw new Error('baseUrl must not contain a user name or password');
    if (rootUrl.protocol !== 'https:' && !(rootUrl.protocol === 'http:' && LOOPBACK.has(rootUrl.hostname))) {
        throw new Error(`Refusing to send the API key to ${root}: use https`);
    }

    const call = async (path, init = {}) => {
        let res;
        try {
            res = await fetch(`${root}/${path}`, {
                ...init,
                redirect: 'manual', // following one could send the key, and even the POST, to another address
                headers: { 'XF-Api-Key': key },
                signal: AbortSignal.timeout(timeoutMs),
            });
        } catch (e) {
            if (e.name === 'TimeoutError') throw e;
            throw new Error(`${path}: ${e.cause?.code ?? e.message}`, { cause: e }); // e.g. ECONNREFUSED, ENOTFOUND
        }
        if (res.status >= 300 && res.status < 400) {
            let to = res.headers.get('location') ?? '';
            try { if (to) to = new URL(to, `${root}/${path}`).href; } catch { /* keep it as sent */ }
            const hint = to.endsWith(`/${path}`) ? `try baseUrl ${to.slice(0, -path.length - 1)}` : 'baseUrl must be the API root, ending in /api';
            throw new Error(`${path}: HTTP ${res.status} redirect to ${to || '(no location)'} (${hint})`);
        }
        const json = await res.json().catch((e) => {
            if (e.name === 'TimeoutError') throw e;
            return null; // an HTML error page is not JSON
        });
        if (!res.ok || !json) throw new Error(`${path}: HTTP ${res.status} ${JSON.stringify(json?.errors ?? null)}`);
        return json;
    };

    const { forum } = await call(`forums/${forumId}/`);
    if (!forum) throw new Error(`forums/${forumId}/: no forum in the response (is baseUrl the API root?)`);
    const matches = (forum.type_data?.prefixes ?? []).filter((p) => plain(p.title) === plain(prefixTitle));
    const prefix = matches.find((p) => p.is_usable) ?? matches[0];
    if (!prefix) throw new Error(`No prefix titled "${prefixTitle}" is enabled in forum ${forumId}`);
    if (!prefix.is_usable) throw new Error(`Prefix "${prefixTitle}" is not usable by this account`);

    const body = new URLSearchParams({ node_id: forumId, title, message, prefix_id: prefix.prefix_id });
    let thread;
    try {
        ({ thread } = await call('threads/', { method: 'POST', body }));
    } catch (e) {
        if (/: HTTP 4\d\d \[/.test(e.message)) throw e; // the API refused it with a JSON 4xx error: nothing was created
        throw new Error(`${e.message} (the thread may have been created: check the forum before retrying)`, { cause: e });
    }
    if (!thread) throw new Error('threads/: no thread in the response (the thread may have been created: check the forum before retrying)');
    if (thread.prefix_id !== prefix.prefix_id) {
        throw new Error(`Thread ${thread.thread_id} was created WITHOUT the "${prefixTitle}" prefix`);
    }
    return thread;
}
These all throw before anything is created: an invalid forum ID; a missing prefix title, title or message; a malformed apiKey (never echoed back); a baseUrl that isn't https (localhost aside) or that contains a user name or password; and anything that goes wrong on the forum lookup: a missing node:read scope, a forum that doesn't exist, a missing or unusable prefix, a redirect (not followed, so your key isn't sent elsewhere; the error suggests the right baseUrl when it can), a timeout (15 s by default), a refused connection or an error page. A create the API refuses with a 4xx JSON error such as please_select_a_prefix creates nothing either. If the final check throws, the thread exists without the prefix, and the error gives its ID. If the create call times out, gets a 5xx, is redirected, loses the connection or returns anything other than a XenForo JSON error, the error says the thread may have been created: it never retries, so check the forum first.

(Checked against the XenForo 2.3.10 source and tested through the REST API on a local 2.3.10 board, helper included, mainly with a user API key holding thread:read, thread:write and node:read.)
 
Back
Top Bottom