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

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

  1. The app loads GET bootstrap once (who am I, people, channels, providers, counts, look).
  2. Lists and threads come from GET threads and GET threads/<id>.
  3. Writes go through Flurfunk\Store, which checks permissions and fires actions.
  4. Flurfunk\Notify listens to those actions, works out who gets what, and stores and delivers notifications at the end of the request (on shutdown).
  5. The app polls GET counts and 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>.
  • null means “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 returns null, 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) calls snapshot() for the person who clicked. If it returns null, 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-img give 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: the target filter of GET threads only matches [a-z_]+:<id>.
  • Target IDs are integers.
  • Guests never see non-post target types in the picker. own-posts channels only work with posts.
  • info() is called without a user. If the URL depends on the person, use current_user_can() inside, as the built-in post target does.
  • Adjust title or link for display with the flurfunk_target_info filter, and permission with flurfunk_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

  1. 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"
    
  2. 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 .id or 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.