diff --git a/Content.Server/_EE/Scribe/ScribeComponent.cs b/Content.Server/_EE/Scribe/ScribeComponent.cs
new file mode 100644
index 00000000000..45c5df3a318
--- /dev/null
+++ b/Content.Server/_EE/Scribe/ScribeComponent.cs
@@ -0,0 +1,40 @@
+namespace Content.Server._EE.Scribe;
+
+[RegisterComponent]
+public sealed partial class ScribeBookComponent : Component
+{
+ ///
+ /// The key to find the Webhook data by.
+ /// See the formatting of the DiscordScribeWebhooks cvar for more information.
+ ///
+ [DataField]
+ public string WebhookKey = "";
+
+ ///
+ /// The fluent format to be used in the Webhook's username.
+ /// Null for default name.
+ ///
+ [DataField]
+ public string? NameFormat = null;
+
+ ///
+ /// The fluent format to be used in the Webhook's content.
+ /// Null for no formatting.
+ ///
+ [DataField]
+ public string? ContentFormat = null;
+
+ ///
+ /// The footer to be provided. Can be a fluent format string but can also be raw.
+ /// Null for no footer.
+ ///
+ [DataField]
+ public string? Footer = null;
+
+ ///
+ /// The colour to be used with the Webhook, in decimal.
+ /// See this guide for more info.
+ ///
+ [DataField]
+ public int Color = 0;
+}
diff --git a/Content.Server/_EE/Scribe/ScribeSystem.cs b/Content.Server/_EE/Scribe/ScribeSystem.cs
new file mode 100644
index 00000000000..9214081390a
--- /dev/null
+++ b/Content.Server/_EE/Scribe/ScribeSystem.cs
@@ -0,0 +1,112 @@
+using System.Linq;
+using Content.Server.GameTicking;
+using Content.Server.Paper;
+using Content.Server.Discord;
+using Content.Shared.CCVar;
+using Robust.Shared.Configuration;
+
+namespace Content.Server._EE.Scribe;
+
+public sealed partial class ScribeSystem : EntitySystem
+{
+ [Dependency] private readonly DiscordWebhook _discord = default!;
+ [Dependency] private readonly IConfigurationManager _config = default!;
+
+ private Dictionary _webHooks = new ();
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ Subs.CVar(_config, CCVars.DiscordScribeWebhooks, cvar => _webHooks = ParseCVarTable(cvar), true);
+
+ SubscribeLocalEvent(OnRoundEnded);
+ }
+
+ private void OnRoundEnded(RoundEndedEvent ev)
+ {
+ // Collect all the scribebooks into groups based on the Webhook they target.
+ foreach (var result in EntityQuery().GroupBy(q => q.Item1.WebhookKey))
+ {
+ if (!_webHooks.TryGetValue(result.Key, out var identifier))
+ continue;
+
+ // Collect them into messages.
+ var embeds = new List();
+
+ foreach (var (scribeComp, paperComp) in result)
+ {
+ var embed = new WebhookEmbed();
+
+ if (scribeComp.NameFormat is { } nameLoc)
+ embed.Title = Loc.GetString(nameLoc, ("name", "Urist McHands")); //TODO: Need to work out how to hold on to people's names.
+
+ embed.Description = scribeComp.ContentFormat is { } contentLoc
+ ? Loc.GetString(contentLoc, ("content", paperComp.Content))
+ : paperComp.Content;
+
+ if (scribeComp.Footer is { } footer)
+ {
+ embed.Footer = new ()
+ {
+ Text = Loc.GetString(footer, ("round-id", ev.RoundId)),
+ };
+ }
+
+ embed.Color = scribeComp.Color;
+
+ embeds.Add(embed);
+ }
+
+ _discord.CreateMessage(identifier, new () { Embeds = embeds, });
+ }
+ }
+
+ private Dictionary ParseCVarTable(string src)
+ {
+ var dict = new Dictionary();
+ foreach (var line in src.Split('\n'))
+ {
+ // Expected format is:
+ //
+ // : /
+ // : /
+ // : /
+
+ // Maps as CCVars when? ;~;
+
+ if (string.IsNullOrWhiteSpace(line))
+ continue;
+
+ if (Split(src, ':') is not { } keyValue)
+ continue;
+
+ if (Split(keyValue.Item2, '/') is not { } idToken)
+ continue;
+
+ dict.Add(keyValue.Item1.Trim(), new (idToken.Item1.Trim(), idToken.Item2.Trim()));
+
+ // Helper function to split the lines.
+ (string, string)? Split(string input, char c)
+ {
+ var parts = input.Split(c, 2);
+ if (parts.Length != 2)
+ {
+ Log.Warning($"Webhook table line missing key: {line}");
+ return null;
+ }
+
+ var (part1, part2) = (parts[0], parts[1]);
+ if (string.IsNullOrWhiteSpace(part1) || string.IsNullOrWhiteSpace(part2))
+ {
+ Log.Warning($"Webhook table line malformed: {line}");
+ return null;
+ }
+
+ return (part1, part2);
+ }
+ }
+
+ return dict;
+ }
+}
diff --git a/Content.Shared/CCVar/CCVars.Discord.cs b/Content.Shared/CCVar/CCVars.Discord.cs
index 10fe6e1f44c..320b076f9a3 100644
--- a/Content.Shared/CCVar/CCVars.Discord.cs
+++ b/Content.Shared/CCVar/CCVars.Discord.cs
@@ -69,4 +69,22 @@ public sealed partial class CCVars
///
public static readonly CVarDef DiscordAuthApiKey =
CVarDef.Create("discord.auth_api_key", "", CVar.SERVERONLY | CVar.CONFIDENTIAL);
+
+ ///
+ /// Table of the Discord webhook URL IDs/tokens which will relay all scribe messages.
+ ///
+ ///
+ /// Expected to be formatted as one <key>: <id>/<token> per line,
+ /// where the key is the same as a key in the ScribeComponent's WebhookKey field and
+ /// the id and token are the same as they appear in a Webhook URL.
+ /// For example
+ ///
+ /// scribe_webhooks: """
+ /// first_scribe_key: 0000000000000000000/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ /// second_scribe_key: 1111111111112222222/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbcccccccccccccccccccccccccccccccccccc
+ /// """
+ ///
+ ///
+ public static readonly CVarDef DiscordScribeWebhooks =
+ CVarDef.Create("discord.scribe_webhooks", string.Empty, CVar.SERVERONLY | CVar.CONFIDENTIAL);
}
diff --git a/Resources/Locale/en-US/_EE/scribe.ftl b/Resources/Locale/en-US/_EE/scribe.ftl
new file mode 100644
index 00000000000..4e09305fead
--- /dev/null
+++ b/Resources/Locale/en-US/_EE/scribe.ftl
@@ -0,0 +1,3 @@
+scribe-name-format = Scribe {$name}
+scribe-content-format = {$content}
+scribe-footer-format = Nanotrasen shift #{$round-id}
diff --git a/Resources/Prototypes/Roles/Jobs/Civilian/librarian.yml b/Resources/Prototypes/Roles/Jobs/Civilian/librarian.yml
index 1556db286f0..013b17c9b7a 100644
--- a/Resources/Prototypes/Roles/Jobs/Civilian/librarian.yml
+++ b/Resources/Prototypes/Roles/Jobs/Civilian/librarian.yml
@@ -54,7 +54,7 @@
outerClothing: ClothingOuterCoatRnd
id: LibrarianPDA
ears: ClothingHeadsetScience
- pocket1: BookPsionicsGuidebook
+ pocket1: PaperScribeBook
pocket2: HandLabeler
innerClothingSkirt: ClothingUniformJumpskirtLibrarian
satchel: ClothingBackpackSatchelLibrarianFilled
diff --git a/Resources/Prototypes/_EE/Entities/Objects/Misc/scribe.yml b/Resources/Prototypes/_EE/Entities/Objects/Misc/scribe.yml
new file mode 100644
index 00000000000..ff81693b7c3
--- /dev/null
+++ b/Resources/Prototypes/_EE/Entities/Objects/Misc/scribe.yml
@@ -0,0 +1,31 @@
+- type: entity
+ name: scribe
+ parent: Paper
+ id: PaperScribeBook
+ description: "A page of the captain's journal. In luxurious lavender."
+ components:
+ - type: Sprite
+ sprite: Objects/Misc/bureaucracy.rsi
+ layers:
+ - state: paper
+ color: "#e6e6fa"
+ - state: paper_words
+ map: ["enum.PaperVisualLayers.Writing"]
+ color: "#e6e6fa"
+ visible: false
+ - state: paper_stamp-generic
+ map: ["enum.PaperVisualLayers.Stamp"]
+ visible: false
+ - type: PaperLabelType
+ paperType: CaptainsPaper
+ - type: PaperVisuals
+ headerImagePath: "/Textures/Interface/Paper/paper_heading_captains_thoughts.svg.96dpi.png"
+ backgroundImagePath: "/Textures/Interface/Paper/paper_background_default.svg.96dpi.png"
+ backgroundModulate: "#e6e6fa"
+ backgroundPatchMargin: 16.0, 16.0, 16.0, 16.0
+ contentMargin: 32.0, 16.0, 32.0, 0.0
+ - type: ScribeBook
+ webhookKey: librarian
+ nameFormat: scribe-name-format
+ contentFormat: scribe-content-format
+ footer: scribe-footer-format