I’d like to propose allowing AI artifacts to send webhooks whenever the stored data in an AI artifact is updated. There are many reasons why this would be advantageous, but I’ll try to keep my case short.
Many organization who make use of Discourse also make use of various automations, and the tools for automations are becoming increasingly popular, easy to set up, and beginner friendly. AI artifacts can contain precious data which can be used very effectively in such automations with the added bonus that it’s already JSON!
In my case, I use n8n in a Docker Compose setup next to my Discourse container and I already use it to automate a wide variety of things, including things on my Discourse instances via a Docker network. However, one of the Discourse instances I maintain is for an educational organization/business and AI artifacts are used to track learner notes, lesson journals, and so on. This kind of data would be really beneficial for improving teachers’ workflows, making summaries, and so on via an automation tool such as n8n.
It would be technically possible to poll artifacts for updates, but that could easily begin to start straining system resources and it would be a waste of resources at best, as well as require extensive technical skills to set up which many admins don’t have.
Needless to say, this would also be highly beneficial for Discourse’s enterprise clients.
A workaround might be for the artifact’s JavaScript to call an external webhook after calling window.discourseArtifact.set(...).
However, this would have some limitations:
-
It is browser-side and therefore not authoritative.
-
It only fires if the artifact JavaScript executes successfully in the user’s browser.
-
It can be affected by CORS, browser privacy tools, network blockers, or sandbox constraints.
-
The payload cannot be trusted as coming from Discourse unless additional server-side verification is built.
-
Secrets cannot be safely embedded in artifact JavaScript.
A server-side event/webhook would be much more reliable and secure.
I'm not a Ruby developer, so I have been chatting with GPT-5.5 about this and it has offered some interesting insight as well…
Based on Discourse’s current webhook architecture, the clean implementation is probably not a bespoke “call this URL” feature inside AI artifacts. It should be implemented as a normal Discourse webhook event type, backed by a normal internal DiscourseEvent.
Best implementation shape
I’d suggest the Discourse devs implement it in two layers:
-
Internal events emitted when artifact KV records change.
-
Webhook event types that subscribe to those internal events and use the existing webhook delivery system.
That keeps it consistent with the rest of Discourse. Existing webhooks already work by mapping internal DiscourseEvents to WebHook.enqueue_* calls in config/initializers/012-web_hook_events.rb; for example, topic, post, user, category, tag, reviewable, notification, and like events are wired this way.
The artifact storage path is simple enough: ArtifactKeyValuesController#set finds or initializes a key-value record, assigns key/value/public, and saves it; destroy finds the current user’s record by key and destroys it. The model itself is AiArtifactKeyValue, belongs to an artifact and a user, has key, value, and public, and enforces uniqueness by artifact/user/key.
Proposed event names
I’d probably use three specific webhook events:
ai_artifact_key_value_created
ai_artifact_key_value_updated
ai_artifact_key_value_deleted
Alternatively, a single event could work:
ai_artifact_key_value_changed
…but three events fit Discourse’s existing style better. Discourse already has separate webhook event names like post_created, post_edited, post_destroyed, calendar_event_created, calendar_event_updated, and so on.
Files/classes they would likely touch
1. Add new webhook event types
WebHookEventType currently defines numeric constants, a group enum, and a TYPES hash of event names to IDs.
They could add something like:
AI_ARTIFACT = 20
enum :group,
{
# existing groups...
ai_artifact: 18,
},
scopes: false
TYPES = {
# existing types...
ai_artifact_key_value_created: 2001,
ai_artifact_key_value_updated: 2002,
ai_artifact_key_value_deleted: 2003,
}
The exact IDs would be up to Discourse maintainers; they just need to avoid conflicts.
They would also add seed entries in db/fixtures/007_web_hook_event_types.rb, because existing webhook event types are seeded there with IDs, names, and groups.
Example:
WebHookEventType.seed do |b|
b.id = WebHookEventType::TYPES[:ai_artifact_key_value_created]
b.name = "ai_artifact_key_value_created"
b.group = WebHookEventType.groups[:ai_artifact]
end
WebHookEventType.seed do |b|
b.id = WebHookEventType::TYPES[:ai_artifact_key_value_updated]
b.name = "ai_artifact_key_value_updated"
b.group = WebHookEventType.groups[:ai_artifact]
end
WebHookEventType.seed do |b|
b.id = WebHookEventType::TYPES[:ai_artifact_key_value_deleted]
b.name = "ai_artifact_key_value_deleted"
b.group = WebHookEventType.groups[:ai_artifact]
end
The admin webhook UI should then pick these up automatically, because Admin::WebHooksController#index already serializes grouped active event types for the UI. The event type serializer already exposes id, name, and group.
2. Hide the events when Discourse AI is disabled
WebHookEventType.active already hides plugin-dependent webhook events when their features are disabled, such as solved, assign, topic voting, chat, and calendar events.
So they could add:
unless defined?(SiteSetting.discourse_ai_enabled) && SiteSetting.discourse_ai_enabled
ids_to_exclude.concat(
[
TYPES[:ai_artifact_key_value_created],
TYPES[:ai_artifact_key_value_updated],
TYPES[:ai_artifact_key_value_deleted],
],
)
end
They may also want to check an artifact-specific setting if one exists or is added.
3. Emit internal events from the model, not just the controller
Although the current write path is controller-based, the robust place is probably the model, using commit callbacks:
class AiArtifactKeyValue < ActiveRecord::Base
after_create_commit :trigger_created_event
after_update_commit :trigger_updated_event
after_destroy_commit :trigger_deleted_event
private
def trigger_created_event
DiscourseEvent.trigger(:ai_artifact_key_value_created, self)
end
def trigger_updated_event
return if previous_changes.slice("value", "public").blank?
DiscourseEvent.trigger(:ai_artifact_key_value_updated, self)
end
def trigger_deleted_event
DiscourseEvent.trigger(:ai_artifact_key_value_deleted, self)
end
end
Model-level callbacks would catch future write paths too, not just ArtifactKeyValuesController#set. The after_commit part matters because webhook delivery should only be queued after the database change is safely committed.
One subtlety: they probably should not trigger an “updated” event when the artifact calls set() with the same value and nothing actually changes. Checking previous_changes avoids noisy webhooks.
4. Add a webhook payload serializer
Discourse webhooks generate payloads through serializers. The generic WebHook.enqueue_object_hooks method can take a serializer, and WebHook.generate_payload serializes the object with a system-user guardian.
They could add something like:
class WebHookAiArtifactKeyValueSerializer < ApplicationSerializer
attributes :id,
:ai_artifact_id,
:post_id,
:topic_id,
:user_id,
:key,
:public,
:value_included,
:created_at,
:updated_at
def post_id
object.ai_artifact.post_id
end
def topic_id
object.ai_artifact.post.topic_id
end
def value_included
false
end
end
I’d strongly recommend not including value by default. Artifact KV data can be private per user, and the model has a public flag. If Discourse wants to support values, they could make it explicit:
include_value: false
include_public_values: true
include_private_values: false
But the safe v1 should probably omit values entirely.
5. Wire internal events to webhook delivery
They could add handlers to config/initializers/012-web_hook_events.rb, mirroring existing patterns.
Something like:
%i[
ai_artifact_key_value_created
ai_artifact_key_value_updated
ai_artifact_key_value_deleted
].each do |event|
DiscourseEvent.on(event) do |key_value|
artifact = key_value.ai_artifact
post = artifact.post
topic = post.topic
payload =
WebHook.generate_payload(
:ai_artifact_key_value,
key_value,
WebHookAiArtifactKeyValueSerializer
)
WebHook.enqueue_hooks(
:ai_artifact_key_value,
event,
id: key_value.id,
category_id: topic&.category_id,
tag_ids: topic&.tags&.pluck(:id),
payload: payload
)
end
end
That would reuse the existing webhook job pipeline. WebHook.enqueue_hooks finds active webhooks for the event and enqueues Jobs::EmitWebHookEvent. The job already handles delivery, retries, logging, headers, signatures, and admin visibility.
Including category_id and tag_ids is a nice touch because existing webhook jobs can filter by category and tag. The webhook job already checks category and tag constraints before sending. Since an AI artifact belongs to a post, and the artifact belongs to a post in the model, deriving category/topic context should be possible.
What the delivered webhook might look like
Because Discourse’s webhook body uses event_type as the top-level root, this would probably look like:
{
"ai_artifact_key_value": {
"id": 456,
"ai_artifact_id": 123,
"post_id": 789,
"topic_id": 321,
"user_id": 42,
"key": "score",
"public": true,
"value_included": false,
"created_at": "2026-05-26T12:00:00Z",
"updated_at": "2026-05-26T12:05:00Z"
}
}
The event name would be in the headers, consistent with existing webhook delivery:
X-Discourse-Event-Type: ai_artifact_key_value
X-Discourse-Event: ai_artifact_key_value_updated
X-Discourse-Event-Signature: sha256=...
Those headers line up with how EmitWebHookEvent builds webhook headers today.
The devs would probably need to decide these:
Should values be included?
My vote: no by default. Maybe allow only public values, or make value inclusion an admin setting. Private per-user artifact data should not silently leave the site.
Should there be one event or three?
Three is cleaner for webhook subscribers. One event with an action field is simpler internally. Existing Discourse style leans toward multiple event names.
Should category/tag filtering apply?
I think yes. The artifact is attached to a post/topic, so category and tag filters are meaningful.
Should this be implemented in Discourse core or the Discourse AI plugin?
The data model lives in the Discourse AI plugin, but the webhook event registry lives in core. Since bundled plugins like solved/chat/calendar already have webhook event types in the shared WebHookEventType list, Discourse may be comfortable adding AI artifact events there too. The active method can hide them when AI is disabled.
Should this also emit an internal DiscourseEvent even if no webhook exists?
Yes. That gives plugin authors a clean hook point even apart from webhooks.
Complexity
I’d frame it as moderate but very contained.
The likely work is:
-
Add 3 event type constants.
-
Add 1 webhook group.
-
Add 3 seed records.
-
Add 1 serializer.
-
Add 3 model callbacks or service-level event triggers.
-
Add webhook initializer handlers.
-
Add specs for create/update/delete.
-
Add privacy decision around whether
valueis included.
The delivery system, retries, signatures, event log, ping/redelivery UI, and admin webhook configuration are already there. The feature is mostly about making artifact KV mutations visible to that existing system.