Developer guide
How Flurfunk is built, how to talk to it, and how to extend it. Complete, copy-paste code is on the examples page.
- Architecture
- Data model
- Data format flurfunk/1
- REST API
- Permissions
- PHP functions
- Providers: slash commands and cards
- Custom card renderers
- Targets
- Embedding
- Hooks
- WP-CLI
- Posting as an assistant from scripts and CI
- Working on Flurfunk itself
Architecture
Flurfunk is plain PHP and plain JavaScript. There is no build step and no framework. The interface is drawn in the browser from the REST API.
| File | Namespace | Job |
|---|---|---|
flurfunk.php |
global | Bootstrap, the public functions flurfunk_*() |
inc/schema.php |
Flurfunk\Schema |
Own tables, versioned (DB_VERSION), upgrade steps |
inc/access.php |
Flurfunk\Access |
Team and guests, channel levels, who sees what, assistants |
inc/store.php |
Flurfunk\Store |
Reading and writing, the data format |
inc/providers.php |
Flurfunk\Providers |
Slash commands and cards, built-in /post |
inc/targets.php |
Flurfunk\Targets |
What threads can be attached to, built-in post |
inc/files.php |
Flurfunk\Files |
Flurfunk files, hidden from the media library, cleanup |
inc/privacy.php |
Flurfunk\Privacy |
Personal data export and erase, user deletion |
inc/settings.php |
Flurfunk\Settings |
Settings → Flurfunk |
inc/notify.php |
Flurfunk\Notify |
Notifications: in-app list, mail, digest, push, reminders, preferences |
inc/push.php |
Flurfunk\Push |
Web Push (VAPID, aes128gcm) without external libraries |
inc/rest.php |
Flurfunk\Rest |
REST API flurfunk/v1 |
inc/admin.php |
Flurfunk\Admin |
Admin page, editor panel, posts column, web app manifest |
inc/transfer.php |
Flurfunk\Transfer |
JSON export and import |
inc/cli.php |
Flurfunk\Cli |
wp flurfunk … |
inc/strings.php |
Flurfunk\Strings |
Translatable strings of the JavaScript |
integrations/mailerpress.php |
Flurfunk\Integrations\MailerPress |
/newsletter cards, loaded only when MailerPress is active |
assets/app.js |
window.Flurfunk |
The interface |
assets/editor.js |
Block editor “Conversations” panel | |
assets/sw.js |
Service worker for push (controls no pages) |
The functions in the namespaces are internal. The stable extension points are the global
functions, the provider and target registries, the hooks, the REST API, the data format and
window.Flurfunk.
Request flow
- The app loads
GET bootstraponce (who am I, people, channels, providers, counts, look). - Lists and threads come from
GET threadsandGET threads/<id>. - Writes go through
Flurfunk\Store, which checks permissions and fires actions. Flurfunk\Notifylistens to those actions, works out who gets what, and stores and delivers notifications at the end of the request (onshutdown).- The app polls
GET countsand the current list every 30 seconds while visible.
Unread numbers are cached per person in transients and invalidated through revision counters
(option flurfunk_rev, user meta flurfunk_rev).
Data model
All tables are prefixed {$wpdb->prefix}flurfunk_.
| Table | Rows | Key columns |
|---|---|---|
channels |
channels | slug (unique), name, topic, access_team, access_guests, position, archived |
threads |
threads (the first post) | channel_id, ref_key, target_type, target_id, title, body, author_id, status ('', open, doing, waiting, done), due_date, resolved_by, resolved_at, created, last_activity |
messages |
replies and log lines | thread_id, type (message, status, event, deleted), author_id, body, status, files (JSON), created, edited |
mentions |
who was mentioned where | thread_id, message_id (0 = the thread itself), user_id |
reads |
per person and thread | last_message_id, seen, follow (1 follow, -1 mute, 0 default), remind_at |
attachments |
cards | thread_id, message_id, provider, ref_key, ref (JSON), snapshot (JSON) |
assignees |
responsible people | thread_id, user_id, assigned_by |
notifications |
the bell list | user_id, thread_id, message_id, kind, actor_id, read_at, sent |
push |
browser subscriptions | user_id, endpoint, p256dh, auth, label, last_ok, failures |
All times are stored in UTC. Text is stored as plain text with mentions as <@ID> and is always
escaped on output.
Other storage:
| Where | Key | Content |
|---|---|---|
| option | flurfunk_settings |
Site settings (see flurfunk_settings filter) |
| option | flurfunk_db_version |
Schema version |
| option | flurfunk_rev |
Global revision counter |
| option | flurfunk_vapid |
Push key pair |
| option | flurfunk_bot_default |
Default assistant for wp flurfunk post |
| user meta | flurfunk_prefs_<key> |
Personal preferences: theme, avatar, mail, push, quiet, badge, hour, channels, pause |
| user meta | flurfunk_bot |
Marks an assistant account |
| user meta | flurfunk_rev, flurfunk_digest_last |
Per-person revision, time of the last digest |
| post meta | _flurfunk_file |
Marks an attachment as a Flurfunk file (hidden from the media library) |
| transients | flurfunk_counts_*, flurfunk_rate_* |
Cached counts, rate limit counters |
Files are normal attachments stored under uploads/flurfunk/<year>/<month>/<random>/.
Data format flurfunk/1
The REST API, the export and the import use the same shapes.
- Times are ISO 8601 in UTC.
- People are user IDs. Mentions in text are
<@ID>. nullmeans “not set”.
Export file
{
"format": "flurfunk/1",
"exported": "2026-10-17T08:00:00+00:00",
"site": "https://example.com/",
"channels": [
{
"id": "general",
"name": "General",
"topic": "Anything that concerns everyone",
"access": { "team": "all", "guests": "none" },
"position": 100,
"archived": false
}
],
"people": [
{ "id": 1, "name": "Ada Admin", "email": "ada@example.com", "kind": "team" },
{ "id": 7, "name": "Ben Editor", "email": "ben@example.com", "kind": "team" }
],
"threads": [
{
"id": 12,
"channel": "general",
"ref_key": null,
"target": { "type": "post", "id": 345 },
"title": "Teaser image",
"body": "Can <@7> find a better picture?",
"author": 1,
"created": "2026-10-15T09:12:00+00:00",
"status": "open",
"resolved": null,
"mentions": [ 7 ],
"attachments": [
{
"id": 3,
"provider": "flurfunk/post",
"ref": { "id": 345 },
"snapshot": {
"title": "Autumn issue",
"subtitle": "Draft · Ada Admin",
"url": "https://example.com/wp-admin/post.php?post=345&action=edit",
"target": { "type": "post", "id": 345 }
}
}
],
"due": "2026-10-20",
"assignees": [ 7 ],
"messages": [
{
"id": 40,
"type": "message",
"author": 7,
"created": "2026-10-15T10:01:00+00:00",
"text": "Here are two options.",
"mentions": [],
"files": [
{ "id": 501, "name": "option-a.jpg", "size": 182311, "type": "image/jpeg",
"url": "https://example.com/wp-content/uploads/flurfunk/2026/10/k3j…/option-a.jpg",
"title": "option-a", "library": false, "bundle": "files/501-option-a.jpg" }
],
"attachments": [],
"edited": null
},
{ "id": 41, "type": "event", "author": 1, "created": "2026-10-15T10:05:00+00:00", "event": "due", "due": "2026-10-20" },
{ "id": 42, "type": "event", "author": 1, "created": "2026-10-15T10:05:10+00:00", "event": "assign", "added": [ 7 ], "removed": [] },
{ "id": 43, "type": "status", "author": 7, "created": "2026-10-16T08:30:00+00:00", "status": "done" },
{ "id": 44, "type": "deleted", "author": 7, "created": "2026-10-16T08:31:00+00:00" }
],
"read": { "1": 44, "7": 43 }
}
]
}
Thread
| Field | Type | Meaning |
|---|---|---|
id |
int | Thread ID |
channel |
string | Channel slug |
ref_key |
string or null | External key set by an embedding plugin or script, e.g. checklist:billing |
target |
{type, id} or null |
What the thread is attached to |
title |
string or null | Optional title |
body |
string | Text of the first post |
author |
int | User ID (0 = removed) |
created |
ISO time | |
status |
open, doing, waiting, done or null |
Task state, see task states; null = not a task |
resolved |
{by, at} or null |
Who ticked it off and when |
mentions |
int[] | People mentioned in the first post |
attachments |
card[] | Cards on the first post |
due |
Y-m-d or null |
Due date |
assignees |
int[] | Responsible people |
messages |
message[] | Export and GET threads/<id> only |
read |
object | Export only: user ID → last message ID read |
Message
type |
Fields |
|---|---|
message |
id, author, created, text, mentions[], files[], attachments[], edited |
status |
id, author, created, status (open, doing, waiting, done, none) |
event |
id, author, created, event (due with due, or assign with added[], removed[]) |
deleted |
id, author, created |
Card (attachment)
| Field | Meaning |
|---|---|
id |
Card row ID (used for cards/<id>/refresh) |
provider |
Provider ID, e.g. flurfunk/post |
ref |
The provider’s reference, e.g. {"id": 12} |
snapshot |
The card data, see snapshot fields |
Task states
| State | Shown as | Meaning |
|---|---|---|
open |
Open | To do |
doing |
In progress | Someone is working on it |
waiting |
Question | Needs an answer before it can go on; notifies the author and the responsible people |
done |
Done | Ticked off; resolved says by whom and when |
open, doing and waiting count as active: they appear in “My tasks”, “Open tasks”, the task
counts and the due reminders. Changing the state adds a status message to the thread. A thread
with status null is not a task.
File
Stored as {id, name, size, type}. A message refers to the attachment; nothing is copied, so a
file from the media library stays where it is. On output Flurfunk adds url, title, library
(false = still a hidden Flurfunk file), and for images preview (medium large), thumb
(thumbnail), width, height. A file deleted from the
media library gets missing: true. Bundle exports add bundle (path inside the folder). Files
imported without a bundle have id: 0 and keep the url of the old site.
REST API
Base: /wp-json/flurfunk/v1/. Every route needs a logged-in user who is team or guest
(including assistants). Inside wp-admin the app authenticates with the cookie and an
X-WP-Nonce header; scripts use an application password (HTTP Basic auth).
Request bodies are JSON (Content-Type: application/json), except file uploads.
Routes
| Method | Route | Parameters | Returns |
|---|---|---|---|
| GET | bootstrap |
Everything the app needs to start, see below | |
| GET | counts |
Unread numbers, see below | |
| GET | threads |
view (all, mentions, tasks, mine, board), channel (slug), target (type:id), person (user ID: only tasks this person is responsible for), ref_prefix, q (search), before (ISO time, for paging by last activity), limit (1–200, default 40), order (ref = oldest first) |
Thread summaries |
| POST | threads |
channel (slug, required), title, body, target {type, id}, task (bool; implied by due or assignees), due (Y-m-d), assignees (user IDs), attachments ([{provider, ref}]), ref_key |
The new thread (full). With a ref_key that exists already: that thread, unchanged |
| GET | threads/<id> |
peek=1 to not mark it as read |
Full thread; marks it and its notifications as read |
| POST | threads/<id>/messages |
body, files (attachment IDs: uploaded Flurfunk files or media library files, up to 50), attachments |
The full thread |
| POST | threads/<id>/status |
status: open, doing, waiting, done, or '' (no longer a task) |
The full thread |
| POST | threads/<id>/edit |
title, body |
The full thread |
| POST | threads/<id>/task |
due (Y-m-d or ''), assignees (user IDs, replaces the list) |
The full thread |
| POST | threads/<id>/follow |
mode: follow, mute or default |
{follow: 1, -1 or 0} |
| POST | threads/<id>/remind |
when: hour, tomorrow, monday, none or an ISO time within a year |
{remind: ISO time or null} |
| POST | messages/<id> |
body |
The full thread |
| DELETE | messages/<id> |
The full thread (the message becomes a deleted note) |
|
| GET | providers/<vendor>/<name>/search |
q |
[{ref, title, subtitle}], at most 20 |
| GET | targets |
q |
[{type, id, title, subtitle, label}] across all target types, at most 30 |
| POST | cards/<id>/refresh |
The full thread with a fresh snapshot | |
| POST | files |
Raw file as body, Content-Disposition: attachment; filename="name.ext", Content-Type |
The file {id, name, size, type, url, …} |
| POST | files/<id>/adopt |
The file, now in the media library | |
| POST | me/prefs |
Any of theme, avatar, mail, push, quiet, badge, hour, pause (1h, 3h, tomorrow, none), channel {slug, level} |
All preferences |
| GET | notifications |
before (notification ID), limit (1–100, default 30) |
The person’s notifications, newest first |
| POST | notifications |
ids (array) or all: true |
Counts |
| POST | read |
channel (optional) |
{marked} plus counts |
| POST | me/push |
subscription {endpoint, keys: {p256dh, auth}}, label |
{devices} |
| DELETE | me/push |
endpoint |
{devices} |
| POST | me/push/test |
{sent: bool} |
Two admin-ajax actions belong to the app: flurfunk_manifest (web app manifest, logged in) and
flurfunk_push_renew (the service worker replaces a rotated push subscription; authenticated by
the old endpoint).
Responses
A thread summary (from GET threads) is the thread shape above plus:
| Field | Meaning |
|---|---|
replies |
Number of replies (message type only) |
participants |
User IDs who replied |
last |
{author, created} of the last reply |
activity |
Last activity (use it as before for the next page) |
unread |
Unread replies for you (+1 if you never opened the thread and did not start it) |
audience |
Guest names who can read it (or “Guests”), shown to the team |
target |
Extended with title, url, status, label |
A full thread (from GET threads/<id> and all write routes) has messages (the newest 300,
see flurfunk_thread_limit) plus:
| Field | Meaning |
|---|---|
older |
Messages not included |
read |
Last message ID you read (-1 = never opened) |
follow |
1 follow, -1 mute, 0 default |
remind |
Your reminder (ISO) or null |
audience |
As above |
Views of GET threads:
view |
Threads | Order |
|---|---|---|
all (default) |
everything visible | last activity |
mentions |
where you were mentioned | last activity |
tasks |
active tasks (open, doing, waiting) |
due date (none last), then activity |
mine |
active tasks you are responsible for | as tasks |
board |
active tasks plus tasks done in the last 14 days (filter flurfunk_board_done_days) |
as tasks |
The board in the app asks for view=board&limit=200, optionally with channel and person.
GET counts: {total, mentions, tasks, mine, overdue, channels: {slug: n}, targets: {"type:id": n}, notifications}.
tasks, mine and overdue count active tasks.
GET bootstrap: format, me (person plus prefs, manage, upload), people,
channels (with your level), providers (id, command, label, hint), counts,
targets (up to 15 you can attach to), recent (targets with recent conversations), locale,
look (name, logo, accent, sidebar), settings (settings URL for admins), push
(available, key, devices).
A person is {id, name, role, kind, bot, avatar}.
Errors
Errors are standard WordPress REST errors: {"code": "…", "message": "…", "data": {"status": …}}.
| Code | Status | When |
|---|---|---|
flurfunk_channel |
400 | Unknown channel slug |
flurfunk_empty |
400 | No text, title, file or card |
flurfunk_invalid |
400 | Reminder time not within the next year |
flurfunk_forbidden |
403 / 404 | Not allowed, or not visible (404 hides that it exists) |
flurfunk_card |
409 | The provider no longer returns a snapshot |
flurfunk_rate |
429 | Rate limit reached |
flurfunk_upload |
400 / 403 / 413 | Empty file, no upload right, file too large |
flurfunk_file |
404 | Not a Flurfunk file (adopt) |
flurfunk_provider |
404 | Unknown or forbidden provider (search) |
flurfunk_push |
400 | Invalid push subscription |
flurfunk_db |
500 | Database write failed |
Permissions
| Check | Rule | Filter |
|---|---|---|
| Uses Flurfunk at all | kind is team or guest |
flurfunk_user_kind |
| Kind | assistant or team capability → team; guest capability → guest | flurfunk_user_kind |
| Channel level | archived → none; otherwise the channel’s team or guest level |
flurfunk_channel_level |
| Sees a thread | level all; or own-posts and the thread is attached to one of their posts |
flurfunk_owned_post_ids |
| Starts a thread | as “sees”, plus may attach to the chosen target | flurfunk_can_target |
| Attaches to a target | the target type’s can callback (default: team, and the target exists) |
flurfunk_can_target |
| Edits or deletes | author or manage_options |
flurfunk_can_edit |
| Changes task state, due date, assignees | sees the thread | flurfunk_can_resolve |
| Is mentioned or assigned | only people who can see the thread | |
| Inserts or refreshes a card | the provider’s can, and snapshot must not return null |
|
| Uploads | upload_files or assistant |
|
| Attaches a media library file by ID | their own files and files they may edit (edit_post); assistants any file not attached to a private post |
flurfunk_can_attach |
| Manages settings and channels | manage_options |
own-posts only ever applies to the target type post.
PHP functions
| Function | Returns | Purpose |
|---|---|---|
flurfunk_register_provider( string $id, array $args ) |
void | Register a slash command with cards. See Providers. |
flurfunk_register_target( string $type, array $args ) |
void | Register something threads can be attached to. See Targets. |
flurfunk_ensure_channel( string $slug, array $args = [] ) |
int channel ID | Create a channel unless it exists. $args: name, topic, access_team, access_guests, position. An existing channel is left unchanged. |
flurfunk_unread_count( int $user_id = 0 ) |
int | Unread messages of a person (0 = current), for badges elsewhere. 0 for people without access. |
flurfunk_url( string $view = '' ) |
string | URL of the app, optionally opening a view. |
Views for flurfunk_url() (and the view query argument of the app page):
| View | Opens |
|---|---|
'' or all |
All threads |
mentions, mine, tasks, board, notifications |
Those views |
channel:<slug> |
A channel |
target:<type>:<id> |
Conversations about a target, e.g. target:post:12 |
thread:<id> |
A thread |
search:<text> |
Search results |
Register providers and targets on init. The built-in target is registered at priority 5, the
built-in provider at priority 20; the default priority 10 is fine for your own.
There is no public PHP function for posting yet. Flurfunk\Store\create_thread() and
Flurfunk\Store\add_message() exist and are what the CLI uses, but they are internal and act as
the current user. For scripts, use WP-CLI or the REST API.
Providers: slash commands and cards
A provider adds a slash command. Its search callback suggests items while the person types,
its snapshot callback turns the chosen item into card data, which is stored with the message.
add_action( 'init', function () {
flurfunk_register_provider( 'myplugin/report', array(
'command' => 'report',
'label' => 'Report',
'hint' => 'Insert a report card',
'search' => fn ( string $q, int $user_id ) => array(
array( 'ref' => array( 'id' => 12 ), 'title' => 'Q3', 'subtitle' => 'Draft' ),
),
'snapshot' => fn ( array $ref, int $user_id ) => array(
'title' => 'Q3',
'metric' => array( 'value' => '1,284', 'label' => 'views' ),
'spark' => array( 3, 5, 2, 8 ),
'url' => admin_url( 'admin.php?page=reports&id=' . (int) $ref['id'] ),
),
'can' => fn ( int $user_id ) => user_can( $user_id, 'edit_posts' ),
) );
} );
Arguments
| Key | Required | Type | Meaning |
|---|---|---|---|
command |
yes | string | Typed as /command. Passed through sanitize_key() (lowercase letters, digits, _, -). |
snapshot |
yes | callable ( array $ref, int $user_id ): ?array |
Card data for a reference. Return null if the person may not see it or it is gone. |
label |
no | string | Shown on the card and in the menu. Default: the command. |
hint |
no | string | One line in the command menu. |
search |
no | callable ( string $q, int $user_id ): array |
Suggestions while typing. Without it the command offers nothing to pick. |
can |
no | callable ( int $user_id ): bool |
Offer the command to this person. Checked for the menu, search, snapshot and refresh. Default: everyone. |
The provider ID should look like vendor/name: the search route only accepts IDs matching
[a-z0-9_-]+/[a-z0-9_-]+.
Search results
Return a list of items. Each needs a ref array; title and subtitle are shown in the menu.
HTML is stripped and at most 20 items are shown.
return array(
array( 'ref' => array( 'id' => 12 ), 'title' => 'Q3 report', 'subtitle' => 'Draft' ),
);
The ref is what gets stored and later passed to snapshot. Keep it small and stable; an id
key is used for lookups (ref_key), otherwise a hash of the whole ref.
The search callback is called with the text after /command (it may be empty after the space).
Snapshot fields
Flurfunk keeps only these fields and cleans them (HTML stripped, URLs escaped):
| Field | Type | Shown as |
|---|---|---|
title |
string | Bold title, linked to url if given |
subtitle |
string | Small line under the title |
text |
string | Paragraph |
url |
URL | Link of the title |
image |
URL | Image at the side |
audio |
URL | Audio player |
metric |
{value, label} |
Big number with a label |
spark |
number[] (up to 60) | Small bar chart |
meta |
string[] (up to 6) | Small facts, joined with “ · “ |
target |
{type, id} |
Stored with the card; the default card does not draw it (yet) |
action |
{label, url} |
Button |
data |
array | Not drawn; free data for your own renderer |
Everything else is dropped. data is passed through JSON, so only plain values survive.
Lifecycle
- Insert: when a message is saved, Flurfunk calls
snapshot()again for the author (not the preview from the menu). If it returnsnull, the card is silently dropped. - Stored: the reference and the snapshot are saved with the message, so the card stays readable when the source changes or disappears, and in exports.
- Update: the “Update” button (
POST cards/<id>/refresh) callssnapshot()for the person who clicked. If it returnsnull, the old snapshot stays and the person sees “This card cannot be updated (any more).” - Unregistered provider: existing cards still show from their snapshot; the Update button disappears.
The built-in /post
flurfunk/post searches posts of the configured post types (guests: only their own) and shows
title, status, author, thumbnail and the edit link. Remove it with:
remove_action( 'init', 'Flurfunk\Providers\register_builtin', 20 );
Custom card renderers
The default card handles most needs. For a special look, register a renderer for your provider ID in JavaScript. It receives the snapshot and a helper object and returns an HTML string.
window.Flurfunk.registerCard( 'myplugin/report', ( s, h ) =>
'<div class="ff-card-body"><b class="ff-card-title">' + h.esc( s.title ) + '</b>'
+ ( s.data && s.data.note ? '<span class="ff-card-text">' + h.rich( s.data.note ) + '</span>' : '' )
+ '</div>'
);
| Helper | Does |
|---|---|
h.esc( value ) |
HTML-escapes a value. Use it for every value you output. |
h.rich( text ) |
Escapes and applies Flurfunk’s formatting |
h.when( iso ) |
A short, localised relative time (“today, 14:05”) |
Rules:
- The returned HTML is inserted as is. Escape everything that comes from data.
- The renderer is used for stored cards. While composing, the card preview uses the default look.
- If the renderer throws, the default card is drawn instead.
- Flurfunk wraps the result in its card frame with the provider label and the Update button.
- The CSS classes
ff-card-body,ff-card-title,ff-card-sub,ff-card-text,ff-card-metric,ff-card-meta,ff-card-action,ff-card-imggive you the default styles.
Load your script after Flurfunk’s (handle flurfunk), on the pages where Flurfunk runs. See the
example.
Targets
A target is something a thread can be attached to. post is built in; plugins add more.
add_action( 'init', function () {
flurfunk_register_target( 'dossier', array(
'label' => 'Dossier',
'search' => fn ( string $q, int $user_id ) => array(
array( 'id' => 12, 'title' => 'Travel', 'subtitle' => '12 posts' ),
),
'info' => fn ( int $id ) => array( 'title' => 'Travel', 'url' => admin_url( '…' ), 'status' => 'open' ),
'can' => fn ( int $user_id, int $id ) => user_can( $user_id, 'edit_others_posts' ),
) );
} );
| Key | Required | Type | Meaning |
|---|---|---|---|
info |
yes | callable ( int $id ): ?array |
title, url, status of the target. Return null when it is gone; the thread then shows “deleted”. |
label |
no | string | Name of the type, shown in the picker. Default: the type key. |
search |
no | callable ( string $q, int $user_id ): array |
Items {id, title, subtitle} for the “Attached to” picker. $q is empty for the initial list. |
can |
no | callable ( int $user_id, int $id ): bool |
May the person attach a thread to this target? Default: team members, if info() returns something. |
Notes:
- The type key goes through
sanitize_key()and is cut to 32 characters. Use only lowercase letters and underscores: thetargetfilter ofGET threadsonly matches[a-z_]+:<id>. - Target IDs are integers.
- Guests never see non-post target types in the picker.
own-postschannels only work with posts. info()is called without a user. If the URL depends on the person, usecurrent_user_can()inside, as the built-in post target does.- Adjust title or link for display with the
flurfunk_target_infofilter, and permission withflurfunk_can_target.
Link to the conversations about a target with flurfunk_url( 'target:dossier:12' ), or embed
them with mountTarget.
Embedding
Flurfunk’s interface can be placed on any wp-admin screen. Load the assets first:
add_action( 'admin_enqueue_scripts', function ( $hook ) {
if ( 'toplevel_page_my-page' === $hook ) {
Flurfunk\Admin\enqueue();
}
} );
Flurfunk\Admin\enqueue() adds assets/app.css, assets/app.js (handle flurfunk) and the
configuration (window.flurfunkConfig: REST root, nonce, URLs, translations), and for people
with upload_files WordPress’s media dialog (wp_enqueue_media()) for “From media library”.
The current user must be able to use Flurfunk; for others the REST calls fail.
Embedded views always use the light theme, the site’s accent colour and Flurfunk’s styles.
window.Flurfunk
| Member | Signature | Purpose |
|---|---|---|
mountApp |
( el ) → Promise |
The full overview. Runs automatically for #flurfunk-app. |
mountChannel |
( el, { channel, newLabel?, titleLabel? } ) → Promise<{refresh}> |
A channel as a numbered checklist |
mountTarget |
( el, { type, id } ) → Promise<{refresh}> |
Conversations about one target (the editor panel uses this) |
mountRefs |
( container, { prefix?, channel, onChange? } ) → Promise |
A thread under each element with data-flurfunk-ref |
registerCard |
( providerId, ( snapshot, h ) => html ) |
Custom card look |
api |
( path, { method?, body? } ) → Promise |
Calls the REST API with the nonce (renews an expired nonce once) |
t |
( text, ...args ) → string |
Translates one of Flurfunk’s strings |
format |
( text ) → html |
The text formatter |
mountChannel
Shows a channel as a numbered list: open items first, done ones below, a form for a new item at the end. Every item shows its full text, cards, task controls and the conversation.
Flurfunk.mountChannel( document.getElementById( 'questions' ), {
channel: 'questions', // channel slug, must exist
newLabel: 'New question', // heading of the form (default "New item")
titleLabel: 'Question', // placeholder of the title field (default "Title")
} );
At most 200 threads are shown, oldest first. A link with #ff-item-<id> scrolls to that item.
mountTarget
Flurfunk.mountTarget( el, { type: 'dossier', id: 12 } );
Lists the threads about the target, lets people reply, tick tasks and start a new conversation
(as a task by default) in any channel they may start threads in. The returned refresh() reloads.
mountRefs
Puts a thread under existing elements, for example the items of a checklist. Each element with a
data-flurfunk-ref attribute gets a reply box (and a tick box if it is a task). The thread is
created on the first reply or tick, so nothing needs seeding.
<ul id="checklist">
<li data-flurfunk-ref="checklist:billing" data-title="Billing" data-task="1">Check the invoices</li>
<li data-flurfunk-ref="checklist:legal" data-title="Legal notice" data-body="Is the address current?">Legal notice</li>
</ul>
<script>
Flurfunk.mountRefs( document.getElementById( 'checklist' ), {
prefix: 'checklist:', // loads existing threads whose ref_key starts with this
channel: 'general', // channel for new threads
onChange: () => {}, // called after a tick
} );
</script>
| Attribute | Meaning |
|---|---|
data-flurfunk-ref |
The thread’s ref_key, unique per item |
data-title |
Title of the thread when it is created |
data-body |
Text of the thread when it is created |
data-task="1" |
The thread is a task (shows a tick box) |
data-channel |
Channel for this item (overrides channel) |
Elements whose thread is done get the class ff-is-done. Up to 200 threads per prefix are loaded.
Two people ticking the same item at the same moment still get one thread: ref_key creation is
locked in the database.
Hooks
Filters
| Filter | Arguments | Return | Purpose |
|---|---|---|---|
flurfunk_settings |
array $settings |
array | Adjust settings in code. Keys: name, logo_id, accent, sidebar, team_cap, guest_cap, post_types, editor_panel, posts_column, keep_data. Applied once per request. |
flurfunk_user_kind |
string $kind, WP_User $user |
'team', 'guest' or '' |
Who is team, guest or has no access. Cached per request. |
flurfunk_channel_level |
string $level, object $channel, int $user_id, string $kind |
'all', 'own-posts' or 'none' |
Per-person access to a channel, e.g. private channels. Other values count as none. Cached per request. |
flurfunk_owned_post_ids |
int[] $ids, int $user_id |
int[] | Posts that count as a person’s own (co-authors). |
flurfunk_post_owners |
int[] $user_ids, WP_Post $post |
int[] | Owners of a post, for the “… can read this” hint only. |
flurfunk_can_target |
bool $can, string $type, int $id, int $user_id |
bool | May the person attach a thread to this target? Also used by the /post snapshot. |
flurfunk_can_resolve |
bool $can, object $thread, int $user_id |
bool | May the person change the task state, due date or assignees? |
flurfunk_can_attach |
bool $can, WP_Post $attachment, int $user_id |
bool | May the person refer to this media library file in a message? Default: own files, files they may edit, and for assistants any file not attached to a private post. |
flurfunk_max_files |
int $max |
int | Files per message. Default 50. |
flurfunk_board_done_days |
int $days |
int | How long done tasks stay on the board. Default 14. |
flurfunk_can_edit |
bool $can, int $author_id, int $user_id |
bool | May the person edit or delete this message or thread? Default: author or manage_options. |
flurfunk_target_info |
array $info, string $type, int $id |
array | type, title, url, status, label of a target for the interface. |
flurfunk_user_role_label |
string $role, WP_User $user |
string | The role shown next to names. |
flurfunk_notify_recipients |
array $to, object $thread, int $message_id |
array | user_id => kind for a new thread ($message_id 0) or message. Not used for task, due, reminder, question (waiting) and done notifications. |
flurfunk_mail |
array {subject, body}, WP_User $user, ?object $thread, int $message_id |
array or false | Change a notification mail, or return false to skip it. $thread is null for the daily digest. |
flurfunk_push_payload |
array {title, body, url, tag, icon}, array $n |
array | Change a push message. $n has uid, kind, thread (row), message_id, actor. |
flurfunk_push_hosts |
string[] $hosts |
string[] | Push services a browser subscription may point to; *.example.com matches subdomains. Default: fcm.googleapis.com, android.googleapis.com, updates.push.services.mozilla.com, *.push.apple.com, *.notify.windows.com. |
flurfunk_push_renew_limit |
int $max |
int | Logged-out subscription renewals per IP address and hour. Default 30; 0 = off. |
flurfunk_rate_limit |
int $max, int $user_id |
int | New threads and messages per minute and person. Default 30, 0 for administrators; 0 = off. |
flurfunk_thread_limit |
int $limit |
int | Messages loaded per thread in the app. Default 300. |
flurfunk_notification_days |
array {read, unread} |
array | Days to keep notifications. Default 90 and 180. |
flurfunk_openstation_icon |
array {title, icon, url, position} |
array or false | Desktop icon when the OpenStation plugin is active; false = none. |
Notification kinds for flurfunk_notify_recipients are mention, assigned, due, remind,
waiting, done, reply, channel and message; other values are ignored. If several notifications for
the same person and event come together, the most important kind wins (in that order).
Recipients you add are not checked against thread access, so check Flurfunk\Access\can_see()
yourself.
object $thread is always a row of the threads table: id, channel_id, ref_key,
target_type, target_id, title, body, author_id, status, due_date, resolved_by,
resolved_at, created, last_activity (database strings).
Actions
| Action | Arguments | Fires |
|---|---|---|
flurfunk_thread_created |
object $thread |
After a thread was created (not on import). |
flurfunk_message_added |
object $thread, int $message_id |
After a reply was added, and after status lines (ticking a task adds a status message). Check the message type if you only want replies. Not on import. |
flurfunk_message_edited |
object $thread, int $message_id |
After a message was edited. |
flurfunk_message_deleted |
object $thread, int $message_id |
After a message was deleted. |
flurfunk_assigned |
object $thread, int[] $added, int $actor |
After people were made responsible. |
flurfunk_status_changed |
object $thread, string $status, int $actor |
After the task state changed (open, doing, waiting, done), or the thread stopped being a task (''). |
Editing a thread’s own title or text (threads/<id>/edit) fires no action.
Cron hooks
flurfunk_remind (every 5 minutes), flurfunk_due and flurfunk_digest (hourly),
flurfunk_cleanup_files (daily; also prunes notifications). See
Cron.
WP-CLI
All commands live under wp flurfunk.
wp flurfunk export
wp flurfunk export [<file>] [--files]
| Option | Meaning |
|---|---|
<file> |
Write to this file instead of the screen. With --files: a folder (required). |
--files |
Write a bundle folder with flurfunk.json and files/, for moving to another site. |
wp flurfunk import
wp flurfunk import <file>
| Option | Meaning |
|---|---|
<file> |
An export file, or a bundle folder from export --files. |
Threads imported before are skipped. People are matched by email. Use the global --user=<admin>
to choose who owns imported files (default: the first administrator).
wp flurfunk status
wp flurfunk status
A table of channels: slug, name, team and guest level, active, number of threads.
wp flurfunk channel
wp flurfunk channel <slug> [--name=<name>] [--topic=<topic>] [--team=<level>] [--guests=<level>] [--position=<n>]
Creates the channel or changes the given details of an existing one. Levels: all, own-posts,
none.
wp flurfunk bot-create
wp flurfunk bot-create [--name=<name>] [--login=<login>] [--default]
| Option | Default | Meaning |
|---|---|---|
--name |
Assistant (translated) |
Display name |
--login |
flurfunk-assistent |
Login name. An existing user with that login is marked as assistant. |
--default |
Use it for wp flurfunk post when no --as is given |
wp flurfunk bot-password
wp flurfunk bot-password <user> [--name=<name>]
Creates an application password for an assistant (user ID) and prints login, password and API
URL. The password is shown once. --name labels it (default Agent).
wp flurfunk post
wp flurfunk post [--channel=<slug>] [--thread=<id>] [--ref=<key>] [--title=<title>] [--body=<text>]
[--task] [--target=<type:id>] [--file=<files>] [--card=<card>] [--as=<user>]
| Option | Meaning |
|---|---|
--channel |
Channel for a new thread |
--thread |
Reply in this thread |
--ref |
Reply in the thread with this ref_key; if there is none, create it (needs --channel) |
--title |
Title of a new thread |
--body |
Text; - reads standard input. Mentions as <@ID>. |
--task |
The new thread is a task |
--target |
Attach a new thread, e.g. post:12 |
--file |
Files to attach, comma-separated paths. They become Flurfunk files. For a new thread they are added as a first reply. |
--card |
One card, provider:json, e.g. 'flurfunk/post:{"id":12}' |
--as |
Author (user ID). Default: the default assistant. |
The command runs with the permissions of the author: the channel level, the target and the rate limit apply. Uploaded files are removed again if posting fails.
wp flurfunk post --channel=general --title="Nightly backup" --body="Done in 4 min." --file=/tmp/backup.log
echo "Build failed on main" | wp flurfunk post --ref=ci:main --channel=dev --title="CI main" --body=-
wp flurfunk post --thread=12 --body="<@7> please have a look" --card='flurfunk/post:{"id":345}'
Posting as an assistant from scripts and CI
-
Create an assistant and an application password (see Assistants):
wp flurfunk bot-create --name="CI" --login=ci-bot wp flurfunk bot-password <id> --name="GitHub Actions" -
Call the REST API with HTTP Basic auth: the assistant’s login and the application password.
SITE=https://example.com
AUTH="ci-bot:abcd efgh ijkl mnop qrst uvwx"
# New thread
curl -sS -u "$AUTH" -H 'Content-Type: application/json' \
-d '{"channel":"dev","title":"Release 2.4.0","body":"Deployed to production."}' \
"$SITE/wp-json/flurfunk/v1/threads"
# Reply in thread 12
curl -sS -u "$AUTH" -H 'Content-Type: application/json' \
-d '{"body":"Smoke tests passed."}' \
"$SITE/wp-json/flurfunk/v1/threads/12/messages"
One thread per topic: ref_key
POST threads with a ref_key is idempotent: if a thread with that key exists, it is returned
unchanged (title and body of the request are ignored); otherwise it is created. Use it to keep
one thread per branch, server or job, and post the news as replies:
TID=$(curl -sS -u "$AUTH" -H 'Content-Type: application/json' \
-d '{"channel":"dev","ref_key":"ci:main","title":"CI: main"}' \
"$SITE/wp-json/flurfunk/v1/threads" | jq -r .id)
curl -sS -u "$AUTH" -H 'Content-Type: application/json' \
-d "$(jq -n --arg b "Build #$BUILD failed" '{body: $b}')" \
"$SITE/wp-json/flurfunk/v1/threads/$TID/messages"
Build JSON with jq (or another tool) rather than by string concatenation, so quotes and line
breaks in messages do not break the request.
Files
Upload the file first, then attach its ID to a reply. A file that is already in the media library can be attached by its attachment ID without uploading it again. A new thread cannot carry files directly; post them as the first reply.
FID=$(curl -sS -u "$AUTH" \
-H 'Content-Disposition: attachment; filename="report.csv"' \
-H 'Content-Type: text/csv' \
--data-binary @report.csv \
"$SITE/wp-json/flurfunk/v1/files" | jq -r .id)
curl -sS -u "$AUTH" -H 'Content-Type: application/json' \
-d "{\"body\":\"Today's report\",\"files\":[$FID]}" \
"$SITE/wp-json/flurfunk/v1/threads/$TID/messages"
Mentions, tasks, cards
{
"channel": "dev",
"title": "Update the SSL certificate",
"body": "<@7> the certificate expires in 10 days.",
"task": true,
"due": "2026-10-25",
"assignees": [ 7 ],
"attachments": [ { "provider": "flurfunk/post", "ref": { "id": 345 } } ]
}
Mentions are <@user-id>. Only people who can see the thread are stored as mentioned or
assigned. Cards are created through their provider, with the assistant’s permissions.
Things to keep in mind
- The assistant sees channels according to the team setting. For a private channel, let it in
through
flurfunk_channel_level. - The rate limit (30 per minute) applies. Batch output into one message.
- Text is cut at 20,000 characters per message. Control characters are removed; HTML is stored as plain text and never rendered.
- A request returns the full thread. Pipe through
jq -r .idor similar if you only need the ID.
More complete scripts (a GitHub Action, a daily report) are on the examples page.
Working on Flurfunk itself
| Task | Command |
|---|---|
| API and permission checks (100+) | wp eval-file tests/api-edge.php <team-id> <team2-id> <guest-id> |
| Browser tests (Playwright) | cd tests/e2e && npm install && FF_BASE=… FF_TEAM=… FF_TEAM_PASS=… npx playwright test |
| Web Push crypto against Node | tests/push/run.sh |
| Formatter | node tests/format.test.cjs |
| Load test (2000 threads, 20000 messages) | wp eval-file tests/load.php |
| Collect JavaScript strings | python3 tools/strings.py (writes inc/strings.php) |
| Build translations | python3 tools/l10n.py (from tools/translations/*.json to languages/*.l10n.php) |
| Release zip | tools/build-zip.sh |
The e2e tests read FF_BASE, FF_TEAM, FF_TEAM_PASS, FF_TEAM2, FF_TEAM2_PASS, FF_GUEST,
FF_GUEST_PASS and optionally FF_QUERY. api-edge.php and load.php clean up after
themselves.
To change the schema, bump DB_VERSION in inc/schema.php. dbDelta adds columns and indexes;
anything else (renames, data rewrites) goes into upgrade() as a step.
Demo content
tests/demo/seed.php fills a test site with a small fictional editorial team, “Northline
Magazine”: five people (editors as team, two authors as guests) and an assistant, the channels
#general, #editorial, #photo, #tech and #authors, four posts, and about a dozen conversations
over the last days, with mentions, tasks in every state and with due dates (one overdue, one due
today), an image gallery, a transcript file, /post cards and notifications for Ana Klein, the
editor-in-chief. The screenshots in these docs were taken with it.
wp eval-file wp-content/plugins/flurfunk/tests/demo/seed.php # add reset-passwords for new ones
cp wp-content/plugins/flurfunk/tests/demo/demo-avatars.php wp-content/mu-plugins/ # pictures, job titles
Without WP-CLI, open the file’s address in the browser while logged in as an administrator
(inside the plugin, or copied into the WordPress root) and confirm; or define
FLURFUNK_DEMO_SECRET (16+ characters) in wp-config.php and add ?secret=… once. Running it
again adds nothing twice (people by login, posts by a meta key, threads by ref_key
northline-demo:…). New accounts get random passwords, printed once. Times are relative to the
moment it runs. Only for test and demo sites: it sets the Flurfunk name (if none is set), changes the five
channels’ access and creates users with @northline.example addresses. The folder is not part of
release zips.