Oct 28, 20267 min read

How to Have an AI Agent Build a Slack Slash Command Internal Tool on AWS

A /refund command that support can run from Slack, built by a coding agent as a TypeScript endpoint with a verified signature, a secret and a Postgres table, and deployed to your own AWS account.

Some of the most used internal tools are a single Slack command. Support types /refund ord_123, the request is logged with who asked for it, and finance sees a list at the end of the day without anyone opening an admin panel. A coding agent can write the handler in a few minutes, and the time goes into what surrounds it: the signature check Slack requires, the signing secret, a database for the log, and a public HTTPS endpoint on AWS that only Slack can usefully call.

This guide builds that /refund command with a coding agent: a TypeScript endpoint that verifies Slack's signature, logs each request to Postgres and answers within three seconds, deployed to your own AWS account. The walkthrough uses Encore for the infrastructure, tested locally with encore run (Encore v1.58.8), and then lists what the same tool takes with Lambda and API Gateway by hand. For tools with a browser UI, How to deploy an AI-built internal tool to AWS with SSO and a private database covers the access side.

Slack command backends at a glance

QuestionWith EncoreHand-built on AWS
Endpointapi.raw route in a serviceLambda function behind API Gateway or a function URL
Signing secretsecret("SlackSigningSecret"), stored in Secrets ManagerSecrets Manager entry plus an IAM policy
Request logSQLDatabase, provisioned as RDSRDS or DynamoDB, with VPC and access setup
Local testencore run with a signed curlsam local or a deployed dev stack
Who can change itAnyone, or any agent, with access to the repositoryWhoever has the AWS and Terraform access

Build the /refund command with Encore

Encore

Encore combines an open source infrastructure SDK for TypeScript and Go with a platform that provisions what the code declares in your own AWS or GCP account. The agent writes the handler, the secret and the database in the same files, encore run starts Postgres locally, and a deploy creates the RDS database, the Secrets Manager entry and the Fargate service in your account, where Encore provisions an IAM role for the service.

Encore declares infrastructure in application code, runs it locally and provisions it in your AWS or GCP account
Infrastructure declared in application code, running locally under encore run and provisioned in your own AWS or GCP account on deploy.

A prompt that gets this right in one pass names the constraints Slack puts on the endpoint:

Add a /refund Slack slash command to the tools service. Verify X-Slack-Signature against the raw body with the signing secret from an Encore secret, reject timestamps older than five minutes, accept only order refs like ord_abc123, log each request with the Slack user in Postgres, and answer in the channel.

The table

The service gets a database with one table for the requests, in tools/migrations/1_create_tables.up.sql:

CREATE TABLE refund_requests ( id BIGSERIAL PRIMARY KEY, order_ref TEXT NOT NULL, requested_by TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now() );
// tools/db.ts import { SQLDatabase } from "encore.dev/storage/sqldb"; export const db = new SQLDatabase("tools", { migrations: "./migrations" });

The command handler

Slack posts the command as a form body and signs v0:<timestamp>:<body> with HMAC-SHA256, as Slack's request verification docs describe. A raw endpoint reads the body as bytes before anything parses it:

// tools/slack.ts import { api } from "encore.dev/api"; import { secret } from "encore.dev/config"; import { createHmac, timingSafeEqual } from "node:crypto"; import { db } from "./db"; // Set with `encore secret set --type dev,prod SlackSigningSecret`. const slackSigningSecret = secret("SlackSigningSecret"); function verifySlack(body: string, timestamp: string, signature: string): boolean { // Reject requests older than five minutes to stop replays. if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false; const expected = "v0=" + createHmac("sha256", slackSigningSecret()).update(`v0:${timestamp}:${body}`).digest("hex"); const a = Buffer.from(expected); const b = Buffer.from(signature); return a.length === b.length && timingSafeEqual(a, b); } // Slack posts the slash command here as application/x-www-form-urlencoded. export const refundCommand = api.raw( { expose: true, method: "POST", path: "/slack/refund" }, async (req, resp) => { const chunks: Buffer[] = []; for await (const chunk of req) chunks.push(chunk as Buffer); const body = Buffer.concat(chunks).toString("utf8"); const ok = verifySlack( body, String(req.headers["x-slack-request-timestamp"] ?? ""), String(req.headers["x-slack-signature"] ?? ""), ); if (!ok) { resp.writeHead(401).end("invalid signature"); return; } const form = new URLSearchParams(body); const orderRef = (form.get("text") ?? "").trim(); const user = form.get("user_name") ?? "unknown"; if (!/^ord_[a-z0-9]+$/.test(orderRef)) { resp.writeHead(200, { "content-type": "application/json" }); resp.end(JSON.stringify({ response_type: "ephemeral", text: "Usage: /refund ord_123" })); return; } const row = await db.queryRow<{ id: number }>` INSERT INTO refund_requests (order_ref, requested_by) VALUES (${orderRef}, ${user}) RETURNING id`; resp.writeHead(200, { "content-type": "application/json" }); resp.end( JSON.stringify({ response_type: "in_channel", text: `Refund request #${row!.id} for ${orderRef} logged by ${user}.`, }), ); }, );

timingSafeEqual compares the signatures in constant time, and the length check first keeps it from throwing on a malformed header. An invalid order ref gets an ephemeral reply only the caller sees, with a 200 so Slack shows it instead of an error.

Testing it without Slack

The agent can test the whole path with curl by signing the request the way Slack does. With SlackSigningSecret: "slack_test" in .secrets.local.cue:

~/tools
$ encore run$ T=$(date +%s); F='text=ord_abc123&user_name=ivan'$ SIG=v0=$(printf "v0:$T:$F" | openssl dgst -sha256 -hmac slack_test -hex | awk '{print $2}')$ curl -X POST localhost:4000/slack/refund -H "x-slack-request-timestamp: $T" -H "x-slack-signature: $SIG" -d "$F"{"response_type":"in_channel","text":"Refund request #1 for ord_abc123 logged by ivan."}$ curl -X POST localhost:4000/slack/refund -H "x-slack-request-timestamp: $T" -H "x-slack-signature: v0=bad" -d "$F"invalid signature

A request with text=bogus returns the ephemeral usage message. The local development dashboard shows each request's trace with the SQL insert.

Deploying it

Set the real signing secret from the Slack app's Basic Information page with encore secret set --type prod SlackSigningSecret, connect your AWS account and push. Encore provisions the RDS database, stores the secret in Secrets Manager and runs the service on Fargate in your own account, and applies the migrations on deploy. Point the slash command's Request URL at https://<your-api-domain>/slack/refund.

The Infrastructure page in the Encore dashboard with a database's configuration panel open, showing fields for vCPUs, memory, storage, availability type, max connections and backups.
Each provisioned resource can be reviewed and its configuration edited per environment in the Encore dashboard. The screenshot shows a GCP Cloud SQL database.

The command lives in the same repository as the rest of the backend, so the next request, such as /refund-status, is another endpoint the agent adds and tests against the same local Postgres. The secrets docs and database docs cover the two primitives used here.

Good to know

Slack shows the user a timeout error when a command gets no response within three seconds, so keep the handler to a database write and a reply. For slower work, publish to a Pub/Sub topic, reply right away, and post the result to the response_url from the subscriber. The request is public by design, which is why the signature check runs before anything else.

The hand-built route on AWS

Without Encore, the agent produces the handler and someone with AWS access sets up the rest:

  • A Lambda function with the handler, and an API Gateway HTTP API or a Lambda function URL in front of it, configured to pass the body through unchanged so the signature still matches.
  • A Secrets Manager entry for the signing secret, an IAM policy allowing secretsmanager:GetSecretValue on it for the function's role, and code that caches the value across invocations.
  • A database for the log: RDS in a VPC, which puts the function in private subnets with a security group, or a DynamoDB table with its own IAM grant.
  • A way to test locally, usually sam local start-api with environment overrides, or a deployed dev stack.

Each piece is a few lines of Terraform or CDK, and the agent can write them, but they are reviewed and applied by a different person than the one who wrote the handler, which slows down the second and third commands.

How to choose

  • Encore if you want internal Slack commands to be endpoints in your backend repository, testable with encore run and deployed to your own AWS account with their secrets and tables.
  • Lambda by hand if the command is a one-off with no database, and your team already has a pattern for Lambda functions and secrets.

Building internal tools with AI agents without giving them your AWS keys covers where cloud access sits when an agent builds the tool, and scheduled reports for internal tools covers the reverse direction, from the backend into Slack.

Frequently asked questions

How do I have an AI agent build a Slack slash command backend?

Ask for an HTTP endpoint that reads the raw form body, verifies the X-Slack-Signature header with the signing secret, rejects old timestamps and answers within three seconds. With Encore, the agent writes an api.raw endpoint, declares the signing secret with secret("SlackSigningSecret") and a database with new SQLDatabase, tests the endpoint locally with encore run, and on deploy Encore provisions the database and secret in your AWS account.

Why does a Slack command endpoint need the raw request body?

Slack signs the exact bytes it sends, prefixed with v0 and the request timestamp. If a framework parses the form body before you compute the HMAC, the reconstructed string can differ and every signature check fails, so the handler has to read the body itself.

How fast does a Slack slash command have to respond?

Slack expects an HTTP response within three seconds. Work that takes longer should be acknowledged right away and finished in the background, posting the result to the response_url Slack includes in the request.

Deploy to your cloud on AWS and Google Cloud
without managing infrastructure

Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.

~/orders — claude
Claude Code
Claude Code v2.1.180Opus 5.5~/orders