diff --git a/.icons/slack.svg b/.icons/slack.svg
new file mode 100644
index 0000000..fb55f72
--- /dev/null
+++ b/.icons/slack.svg
@@ -0,0 +1,6 @@
+
diff --git a/slackme/README.md b/slackme/README.md
new file mode 100644
index 0000000..017f06a
--- /dev/null
+++ b/slackme/README.md
@@ -0,0 +1,81 @@
+---
+display_name: Slack Me
+description: Send a Slack message when a command finishes inside a workspace!
+icon: ../.icons/slack.svg
+maintainer_github: coder
+verified: true
+tags: [helper]
+---
+
+# Slack Me
+
+Add the `slackme` command to your workspace that DMs you on Slack when your command finishes running.
+
+```bash
+$ slackme npm run long-build
+```
+
+## Setup
+
+1. Navigate to [Create a Slack App](https://api.slack.com/apps?new_app=1) and select "From an app manifest". Select a workspace and paste in the following manifest, adjusting the redirect URL to your Coder deployment:
+
+ ```json
+ {
+ "display_information": {
+ "name": "Command Notify",
+ "description": "Notify developers when commands finish running inside Coder!",
+ "background_color": "#1b1b1c"
+ },
+ "features": {
+ "bot_user": {
+ "display_name": "Command Notify"
+ }
+ },
+ "oauth_config": {
+ "redirect_urls": [
+ "https:///external-auth/slack/callback"
+ ],
+ "scopes": {
+ "bot": ["chat:write"]
+ }
+ }
+ }
+ ```
+
+2. In the "Basic Information" tab on the left after creating your app, scroll down to the "App Credentials" section. Set the following environment variables in your Coder deployment:
+
+ ```env
+ CODER_EXTERNAL_AUTH_1_TYPE=slack
+ CODER_EXTERNAL_AUTH_1_SCOPES="chat:write"
+ CODER_EXTERNAL_AUTH_1_DISPLAY_NAME="Slack Me"
+ CODER_EXTERNAL_AUTH_1_CLIENT_ID="
+ CODER_EXTERNAL_AUTH_1_CLIENT_SECRET=""
+ ```
+
+3. Restart your Coder deployment. Any Template can now import the Slack Me module, and `slackme` will be available on the `$PATH`:
+
+ ```hcl
+ module "slackme" {
+ source = "https://registry.coder.com/modules/slackme"
+ agent_id = coder_agent.example.id
+ auth_provider_id = "slack"
+ }
+ ```
+
+## Examples
+
+### Custom Slack Message
+
+- `$COMMAND` is replaced with the command the user executed.
+- `$DURATION` is replaced with a human-readable duration the command took to execute.
+
+```hcl
+module "slackme" {
+ source = "https://registry.coder.com/modules/slackme"
+ agent_id = coder_agent.example.id
+ auth_provider_id = "slack"
+ slack_message = < {
+ await runTerraformInit(import.meta.dir);
+
+ testRequiredVariables(import.meta.dir, {
+ agent_id: "foo",
+ auth_provider_id: "foo",
+ });
+
+ it("writes to path as executable", async () => {
+ const { instance, id } = await setupContainer();
+ await writeCoder(id, "exit 0");
+ let exec = await execContainer(id, ["sh", "-c", instance.script]);
+ expect(exec.exitCode).toBe(0);
+ exec = await execContainer(id, ["sh", "-c", "which slackme"]);
+ expect(exec.exitCode).toBe(0);
+ expect(exec.stdout.trim()).toEqual("/usr/bin/slackme");
+ });
+
+ it("prints usage with no command", async () => {
+ const { instance, id } = await setupContainer();
+ await writeCoder(id, "echo 👋");
+ let exec = await execContainer(id, ["sh", "-c", instance.script]);
+ expect(exec.exitCode).toBe(0);
+ exec = await execContainer(id, ["sh", "-c", "slackme"]);
+ expect(exec.stdout.trim()).toStartWith(
+ "slackme — Send a Slack notification when a command finishes",
+ );
+ });
+
+ it("displays url when not authenticated", async () => {
+ const { instance, id } = await setupContainer();
+ await writeCoder(id, "echo 'some-url' && exit 1");
+ let exec = await execContainer(id, ["sh", "-c", instance.script]);
+ expect(exec.exitCode).toBe(0);
+ exec = await execContainer(id, ["sh", "-c", "slackme echo test"]);
+ expect(exec.stdout.trim()).toEndWith("some-url");
+ });
+
+ it("default output", async () => {
+ await assertSlackMessage({
+ command: "echo test",
+ durationMS: 2,
+ output: "👨💻 `echo test` completed in 2ms",
+ });
+ });
+
+ it("formats multiline message", async () => {
+ await assertSlackMessage({
+ command: "echo test",
+ format: `this command:
+\`$COMMAND\`
+executed`,
+ output: `this command:
+\`echo test\`
+executed`,
+ });
+ });
+
+ it("formats execution with milliseconds", async () => {
+ await assertSlackMessage({
+ command: "echo test",
+ format: `$COMMAND took $DURATION`,
+ durationMS: 150,
+ output: "echo test took 150ms",
+ });
+ });
+
+ it("formats execution with seconds", async () => {
+ await assertSlackMessage({
+ command: "echo test",
+ format: `$COMMAND took $DURATION`,
+ durationMS: 15000,
+ output: "echo test took 15.0s",
+ });
+ });
+
+ it("formats execution with minutes", async () => {
+ await assertSlackMessage({
+ command: "echo test",
+ format: `$COMMAND took $DURATION`,
+ durationMS: 120000,
+ output: "echo test took 2m 0.0s",
+ });
+ });
+
+ it("formats execution with hours", async () => {
+ await assertSlackMessage({
+ command: "echo test",
+ format: `$COMMAND took $DURATION`,
+ durationMS: 60000 * 60,
+ output: "echo test took 1hr 0m 0.0s",
+ });
+ });
+});
+
+const setupContainer = async (
+ image = "alpine",
+ vars: Record = {},
+) => {
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "foo",
+ auth_provider_id: "foo",
+ ...vars,
+ });
+ const instance = findResourceInstance(state, "coder_script");
+ const id = await runContainer(image);
+ return { id, instance };
+};
+
+const writeCoder = async (id: string, script: string) => {
+ const exec = await execContainer(id, [
+ "sh",
+ "-c",
+ `echo '${script}' > /usr/bin/coder && chmod +x /usr/bin/coder`,
+ ]);
+ expect(exec.exitCode).toBe(0);
+};
+
+const assertSlackMessage = async (opts: {
+ command: string;
+ format?: string;
+ durationMS?: number;
+ output: string;
+}) => {
+ let url: URL;
+ const fakeSlackHost = serve({
+ fetch: (req) => {
+ url = new URL(req.url);
+ if (url.pathname === "/api/chat.postMessage")
+ return createJSONResponse({
+ ok: true,
+ });
+ return createJSONResponse({}, 404);
+ },
+ port: 0,
+ });
+ const { instance, id } = await setupContainer(
+ "alpine/curl",
+ opts.format && {
+ slack_message: opts.format,
+ },
+ );
+ await writeCoder(id, "echo 'token'");
+ let exec = await execContainer(id, ["sh", "-c", instance.script]);
+ expect(exec.exitCode).toBe(0);
+ exec = await execContainer(id, [
+ "sh",
+ "-c",
+ `DURATION_MS=${opts.durationMS || 0} SLACK_URL="http://${
+ fakeSlackHost.hostname
+ }:${fakeSlackHost.port}" slackme ${opts.command}`,
+ ]);
+ expect(exec.stderr.trim()).toBe("");
+ expect(url.pathname).toEqual("/api/chat.postMessage");
+ expect(url.searchParams.get("channel")).toEqual("token");
+ expect(url.searchParams.get("text")).toEqual(opts.output);
+};
diff --git a/slackme/main.tf b/slackme/main.tf
new file mode 100644
index 0000000..5fe948e
--- /dev/null
+++ b/slackme/main.tf
@@ -0,0 +1,46 @@
+terraform {
+ required_version = ">= 1.0"
+
+ required_providers {
+ coder = {
+ source = "coder/coder"
+ version = ">= 0.12"
+ }
+ }
+}
+
+variable "agent_id" {
+ type = string
+ description = "The ID of a Coder agent."
+}
+
+variable "auth_provider_id" {
+ type = string
+ description = "The ID of an external auth provider."
+}
+
+variable "slack_message" {
+ type = string
+ description = "The message to send to Slack."
+ default = "👨💻 `$COMMAND` completed in $DURATION"
+}
+
+resource "coder_script" "install_slackme" {
+ agent_id = var.agent_id
+ display_name = "install_slackme"
+ run_on_start = true
+ script = < $CODER_DIR/slackme <
+
+Example: slackme npm run long-build
+EOF
+}
+
+pretty_duration() {
+ local duration_ms=$1
+
+ # If the duration is less than 1 second, display in milliseconds
+ if [ $duration_ms -lt 1000 ]; then
+ echo "$${duration_ms}ms"
+ return
+ fi
+
+ # Convert the duration to seconds
+ local duration_sec=$((duration_ms / 1000))
+ local remaining_ms=$((duration_ms % 1000))
+
+ # If the duration is less than 1 minute, display in seconds (with ms)
+ if [ $duration_sec -lt 60 ]; then
+ echo "$${duration_sec}.$${remaining_ms}s"
+ return
+ fi
+
+ # Convert the duration to minutes
+ local duration_min=$((duration_sec / 60))
+ local remaining_sec=$((duration_sec % 60))
+
+ # If the duration is less than 1 hour, display in minutes and seconds
+ if [ $duration_min -lt 60 ]; then
+ echo "$${duration_min}m $${remaining_sec}.$${remaining_ms}s"
+ return
+ fi
+
+ # Convert the duration to hours
+ local duration_hr=$((duration_min / 60))
+ local remaining_min=$((duration_min % 60))
+
+ # Display in hours, minutes, and seconds
+ echo "$${duration_hr}hr $${remaining_min}m $${remaining_sec}.$${remaining_ms}s"
+}
+
+if [ $# -eq 0 ]; then
+ usage
+ exit 1
+fi
+
+BOT_TOKEN=$(coder external-auth access-token $PROVIDER_ID)
+if [ $? -ne 0 ]; then
+ printf "Authenticate with Slack to be notified when a command finishes:\n$BOT_TOKEN\n"
+ exit 1
+fi
+
+USER_ID=$(coder external-auth access-token $PROVIDER_ID --extra "authed_user.id")
+if [ $? -ne 0 ]; then
+ printf "Failed to get authenticated user ID:\n$USER_ID\n"
+ exit 1
+fi
+
+START=$(date +%s%N)
+# Run all arguments as a command
+$@
+END=$(date +%s%N)
+DURATION_MS=$${DURATION_MS:-$(( (END - START) / 1000000 ))}
+PRETTY_DURATION=$(pretty_duration $DURATION_MS)
+
+set -e
+COMMAND=$(echo $@)
+SLACK_MESSAGE=$(echo "$SLACK_MESSAGE" | sed "s|\\$COMMAND|$COMMAND|g")
+SLACK_MESSAGE=$(echo "$SLACK_MESSAGE" | sed "s|\\$DURATION|$PRETTY_DURATION|g")
+
+curl --silent -o /dev/null --header "Authorization: Bearer $BOT_TOKEN" \
+ -G --data-urlencode "text=$${SLACK_MESSAGE}" \
+ "$SLACK_URL/api/chat.postMessage?channel=$USER_ID&pretty=1"
diff --git a/test.ts b/test.ts
index a32a995..37e0805 100644
--- a/test.ts
+++ b/test.ts
@@ -13,6 +13,8 @@ export const runContainer = async (
"-d",
"--label",
"modules-test=true",
+ "--network",
+ "host",
"--entrypoint",
"sh",
image,