diff --git a/database/schema.sql b/database/schema.sql
index ba88760..e0eaeb6 100644
--- a/database/schema.sql
+++ b/database/schema.sql
@@ -33,6 +33,7 @@ CREATE TABLE IF NOT EXISTS notifications (
title TEXT NOT NULL,
body TEXT NOT NULL,
link TEXT,
+ actions_json TEXT,
mandatory INTEGER NOT NULL DEFAULT 0 CHECK(mandatory IN (0, 1)),
created_at TEXT NOT NULL,
read_at TEXT,
@@ -87,6 +88,37 @@ CREATE TABLE IF NOT EXISTS oversight_notification_queue (
deliver_after TEXT NOT NULL,
delivered_at TEXT
);
+CREATE TABLE IF NOT EXISTS automated_system_messages (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL UNIQUE,
+ description TEXT NOT NULL,
+ event_key TEXT NOT NULL CHECK(event_key IN ('account.created')),
+ enabled INTEGER NOT NULL DEFAULT 1 CHECK(enabled IN (0,1)),
+ send_delay_minutes INTEGER NOT NULL DEFAULT 0 CHECK(send_delay_minutes BETWEEN 0 AND 10080),
+ source TEXT NOT NULL,
+ title TEXT NOT NULL,
+ body TEXT NOT NULL,
+ link TEXT,
+ actions_json TEXT,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL,
+ updated_by TEXT REFERENCES users(id)
+);
+CREATE TABLE IF NOT EXISTS automated_system_message_deliveries (
+ id TEXT PRIMARY KEY,
+ message_id TEXT NOT NULL REFERENCES automated_system_messages(id) ON DELETE CASCADE,
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ event_key TEXT NOT NULL,
+ event_id TEXT NOT NULL,
+ scheduled_for TEXT NOT NULL,
+ delivered_at TEXT,
+ notification_id TEXT REFERENCES notifications(id) ON DELETE SET NULL,
+ created_at TEXT NOT NULL,
+ UNIQUE(message_id,user_id,event_key,event_id)
+);
+CREATE INDEX IF NOT EXISTS automated_system_message_deliveries_due_idx ON automated_system_message_deliveries(delivered_at,scheduled_for);
+INSERT OR IGNORE INTO automated_system_messages(id,name,description,event_key,enabled,send_delay_minutes,source,title,body,link,actions_json,created_at,updated_at)
+VALUES('welcome-agent','Welcome Agent','Introduces newly registered agents to SHD.build community channels and contribution workflows','account.created',1,0,'ISAC COMMUNITY NETWORK','Welcome to SHD.build','Welcome, Agent. SHD.build is your community intelligence hub for The Division Resurgence. Explore field news, search the game database, discover community builds and guides, or configure your own loadout in the Builder. ISAC also lets every registered agent inspect game data and enable Contributor Mode to suggest additions and corrections for Oversight review. Join the SHD Discord to meet other agents, share intelligence, and help the network grow.','/account','[{"label":"EXPLORE NEWS","href":"/news"},{"label":"QUERY DATABASE","href":"/database"},{"label":"VIEW COMMUNITY BUILDS","href":"/builds"},{"label":"READ FIELD GUIDES","href":"/guides"},{"label":"OPEN BUILDER","href":"/builder"},{"label":"CONTRIBUTE THROUGH ISAC","href":"/isac/contributor-guidelines"},{"label":"JOIN SHD DISCORD","href":"https://discord.gg/dhpd33VwQK"}]','2026-07-26T00:00:00.000Z','2026-07-26T00:00:00.000Z');
CREATE INDEX IF NOT EXISTS oversight_notification_queue_due_idx ON oversight_notification_queue(delivered_at, deliver_after);
CREATE INDEX IF NOT EXISTS oversight_notification_queue_user_category_idx ON oversight_notification_queue(user_id, oversight_category, delivered_at);
CREATE INDEX IF NOT EXISTS avatar_reviews_status_submitted_idx ON avatar_reviews(status, submitted_at DESC);
@@ -207,6 +239,7 @@ INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-users-
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-audit-view','audit.view','View and search the immutable ISAC audit history');
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-audit-revert','audit.revert','Attempt confirmed compensating reversals of supported ISAC audit events');
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-metrics-view','metrics.view','View read-only application, runtime, and database health metrics');
+INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-system-notifications-manage','system.notifications.manage','View and edit automated ISAC system notification messages');
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-avatars-review','avatars.review','Review and remove custom user avatars');
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-data-editor','permission-isac-access');
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-data-editor','permission-data-manage');
diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md
index 1b0d5f8..c52a934 100644
--- a/docs/DESIGN_SYSTEM.md
+++ b/docs/DESIGN_SYSTEM.md
@@ -129,6 +129,21 @@ daily delivery targets 10:00 AM in `America/New_York` so the Eastern wall-clock
time remains stable across daylight-saving transitions. On mobile, cadence
segments wrap to two columns and each review category becomes its own row.
+ISAC System Configuration is a distinct navigation domain from System
+Oversight. It contains operational configuration such as Automated Messages and
+read-only observability such as System Metrics. Automated-message editors use
+the standard horizontal edit lifecycle: successful edits close the editor,
+validation preserves it, and event triggers remain explicit. Content, enabled
+state, post-event delay, primary destination, and up to eight action callouts
+are displayed as structured fields rather than raw JSON.
+
+Automated ISAC messages may expose multiple square action callouts in the
+notification inbox and detail view. Callouts wrap on desktop and become
+full-width touch targets on mobile. Each retains a specific verb and
+destination; do not replace the set with a generic “open” action. External
+community actions such as Discord use secure HTTPS destinations while internal
+actions use routed paths.
+
Contact notifications use the mandatory ISAC visual identity because they are
operational inbound messages. The notification carries a concise preview and a
specific action to an authorization-checked full record. Full messages never
diff --git a/docs/architecture.md b/docs/architecture.md
index f2b5357..9e78977 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -342,3 +342,29 @@ window. Daily rows are assigned to the next 10:00 AM wall-clock boundary in
dispatched rows retain `delivered_at` evidence and are never selected again.
The existing ISAC notification delivery preferences still determine optional
browser and email fan-out after the consolidated inbox message is created.
+
+## Automated ISAC system messages
+
+Automated message definitions live in `automated_system_messages` and are
+managed through ISAC → System Configuration → Automated Messages by agents with
+`system.notifications.manage`. Edits are recorded under the
+`system_configuration` audit category. Definitions identify a lifecycle event,
+enabled state, delay in minutes, message content, primary destination, and
+structured action callouts.
+
+`account.created` is emitted by password, magic-link, and Discord registration.
+The delivery table has a unique message/user/event/event-ID constraint, so the
+same account creation cannot deliver the Welcome Agent message more than once.
+The default welcome introduces News, Database, Builds, Guides, Builder, ISAC
+Contributor Mode, and the SHD Discord community.
+
+Immediate definitions are delivered in the registration request. Definitions
+with a delay are persisted in `automated_system_message_deliveries`. Production
+must run the delayed dispatcher once per minute:
+
+```text
+* * * * * cd /path/to/shd.build && npm run notifications:system:production
+```
+
+Only one dispatcher instance should run for the embedded database. Delivered
+rows retain both their delivery timestamp and generated notification ID.
diff --git a/package.json b/package.json
index 77aaf5e..8a97a9e 100644
--- a/package.json
+++ b/package.json
@@ -19,6 +19,8 @@
"notifications:digest:production": "dotenvx run -f .env.production -- node scripts/notification-digest.mjs",
"notifications:oversight": "dotenvx run -f .env.development -- node scripts/oversight-notification-dispatch.mjs",
"notifications:oversight:production": "dotenvx run -f .env.production -- node scripts/oversight-notification-dispatch.mjs",
+ "notifications:system": "dotenvx run -f .env.development -- node scripts/system-notification-dispatch.mjs",
+ "notifications:system:production": "dotenvx run -f .env.production -- node scripts/system-notification-dispatch.mjs",
"notifications:seed-test": "dotenvx run -f .env.development -- node scripts/seed-test-notifications.mjs",
"assets:trim-icons": "node scripts/trim-static-icons.mjs",
"env:encrypt": "node scripts/env-files.mjs encrypt",
diff --git a/scripts/db-migrate.mjs b/scripts/db-migrate.mjs
index 246d536..e36ca5c 100644
--- a/scripts/db-migrate.mjs
+++ b/scripts/db-migrate.mjs
@@ -93,6 +93,12 @@ for (const [name, definition] of [
await database.exec("UPDATE data_change_proposals SET draft_entity_id=id WHERE draft_entity_id IS NULL AND entity_id IS NULL");
await database.exec('CREATE UNIQUE INDEX IF NOT EXISTS data_change_proposals_draft_entity_idx ON data_change_proposals(draft_entity_id) WHERE draft_entity_id IS NOT NULL');
+const notificationColumns = await (await database.prepare("PRAGMA table_info('notifications')")).all();
+if (!notificationColumns.some((column) => column.name === 'actions_json')) {
+ console.log('Adding structured action callouts to notifications.');
+ await database.exec('ALTER TABLE notifications ADD COLUMN actions_json TEXT');
+}
+
const proposalDefinition = await (await database.prepare(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='data_change_proposals'"
)).get();
diff --git a/scripts/system-notification-dispatch.mjs b/scripts/system-notification-dispatch.mjs
new file mode 100644
index 0000000..5577676
--- /dev/null
+++ b/scripts/system-notification-dispatch.mjs
@@ -0,0 +1,66 @@
+import { connect } from '@tursodatabase/database';
+import nodemailer from 'nodemailer';
+import { randomUUID } from 'node:crypto';
+import { resolve } from 'node:path';
+
+const database = await connect(resolve(process.env.TDR_DATABASE_PATH || './data/shd-build.db'));
+const now = new Date().toISOString();
+const due = await (await database.prepare(`SELECT delivery.id AS delivery_id,delivery.user_id,message.*
+ FROM automated_system_message_deliveries delivery
+ JOIN automated_system_messages message ON message.id=delivery.message_id
+ WHERE delivery.delivered_at IS NULL AND delivery.scheduled_for<=? AND message.enabled=1
+ ORDER BY delivery.scheduled_for LIMIT 250`)).all(now);
+const smtpConfigured = Boolean(process.env.SMTP_HOST && process.env.SMTP_FROM);
+const transport = smtpConfigured ? nodemailer.createTransport({
+ host: process.env.SMTP_HOST,
+ port: Number(process.env.SMTP_PORT || 587),
+ secure: process.env.SMTP_SECURE === 'true',
+ auth: process.env.SMTP_USER ? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASSWORD } : undefined,
+ requireTLS: process.env.SMTP_SECURE !== 'true',
+ disableFileAccess: true,
+ disableUrlAccess: true
+}) : null;
+const origin = (process.env.APP_ORIGIN || 'http://localhost:5173').replace(/\/+$/, '');
+const escapeHtml = (value) => String(value).replace(/[&<>"']/g, (character) => ({
+ '&': '&',
+ '<': '<',
+ '>': '>',
+ '"': '"',
+ "'": '''
+})[character]);
+let delivered = 0;
+
+for (const message of due) {
+ const notificationId = randomUUID();
+ await (await database.prepare(`INSERT INTO notifications
+ (id,user_id,category,source,title,body,link,actions_json,mandatory,created_at)
+ VALUES(?,?,'isac',?,?,?,?,?,1,?)`)).run(
+ notificationId, message.user_id, message.source, message.title, message.body,
+ message.link ?? null, message.actions_json ?? null, now
+ );
+ await (await database.prepare(`UPDATE automated_system_message_deliveries
+ SET delivered_at=?,notification_id=? WHERE id=? AND delivered_at IS NULL`))
+ .run(now, notificationId, message.delivery_id);
+ const preference = await (await database.prepare(
+ "SELECT email_frequency FROM notification_preferences WHERE user_id=? AND category='isac'"
+ )).get(message.user_id);
+ if (transport && preference?.email_frequency === 'immediate') {
+ const identity = await (await database.prepare('SELECT email FROM user_emails WHERE user_id=?')).get(message.user_id);
+ if (typeof identity?.email === 'string') {
+ try {
+ await transport.sendMail({
+ from: process.env.SMTP_FROM,
+ to: identity.email,
+ subject: `SHD.build · ${message.title}`,
+ text: `${message.body}\n\n${message.link ? `${origin}${message.link}` : origin}`,
+ html: `
{#if !selected.read_at}{/if}{#if storedActions(selected).length}{#each storedActions(selected) as callout}{callout.label} →{/each}{:else if action(selected)}{action(selected)?.label} →{/if}