AWS Builder Center

Harness Engineering Part 2: Putting the Ops Agent in Slack

The Part 1 agent answers grounded FinOps and ownership questions. Now it moves into Slack with a signed slash command, a 3-second adapter Lambda, an async worker, Block Kit output and a second evidence-contract check at the trust boundary.

Digital Lead
In Part 1  we built an ops agent on Amazon Bedrock AgentCore with Strands Agents. Its job is to answer questions like "why did our Bedrock bill spike?" or "who owns this account?" The core idea was the evidence contract: a Zod schema (GroundedAnswer) whose superRefine validator makes an ungrounded answer structurally impossible rather than just discouraged by the prompt.
An agent that only lives in a terminal is a demo, though. The people asking "who owns this account?" might not all use a TUI. So in Part 2 we'll build a Slack
The goal is:
  • A /ask-ops slash command
  • An adapter Lambda that verifies Slack's signature and replies within 3 seconds
  • A worker Lambda that calls the AgentCore Runtime, re-checks the answer against the contract and posts it to the channel
  • Everything in Terraform, with least-privilege IAM

Here's the idea

You might wonder why there is two Lambdas, we'll get in to that in a sec.

Why can't we do this with one Lambda?

The easiest solution is a single Lambda: receive the slash command, call the agent, return the answer. Try it and Slack gives you this:
/ask-ops failed with the error "operation_timeout"
Slack waits 3 seconds for a slash command response. An agent that calls Cost Explorer, reads the Enterprise Data Model from S3 and reasons over both will have a hard time making it in that time.
Therefore we will build:
An adapter: verify, hand off, acknowledge. It has to be fast.
A worker: do the slow work and reply later through the response_url Slack sends with every command.

Create the Slack app

  1. Go to api.slack.com/apps  > Create New App > From scratch.
  2. Under Basic Information, copy the Signing Secret. Terraform will store it for us.
  3. Under Slash Commands, create /ask-ops. Leave the Request URL as a placeholder; Terraform gives us the real one.
  4. Install the app to your workspace.

Verify every request

The adapter is served on a public URL so this check is the authentication. Slack signs v0:{timestamp}:{body} with HMAC-SHA256 using your signing secret. We mimic that and compare.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// src/slack/verify.ts
import { createHmac, timingSafeEqual } from "node:crypto";

type Headers = Record<string, string | undefined>;

// Slack signs every request with your app's signing secret.
export function isFromSlack(body: string, headers: Headers, secret: string): boolean {
const timestamp = headers["x-slack-request-timestamp"];
const signature = headers["x-slack-signature"];
if (!timestamp || !signature) return false;

// Older than 5 minutes? Could be a captured request being replayed
const ageInSeconds = Date.now() / 1000 - Number(timestamp);
if (Math.abs(ageInSeconds) > 300) return false;

const expected = "v0=" + createHmac("sha256", secret).update(`v0:${timestamp}:${body}`).digest("hex");

// timingSafeEqual takes the same time wherever the first mismatch is, so timing leaks nothing
const a = Buffer.from(signature);
const b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
}
You might wonder why there's no check for a garbage timestamp. The timestamp is part of the signed string, so nobody can change it without breaking the signature.
Four tests cover everything that matters:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// src/slack/verify.test.ts
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createHmac } from "node:crypto";
import { isFromSlack } from "./verify";

const secret = "test-secret";
const body = "text=who+owns+account+123";
const now = 1_700_000_000; // seconds

// Sign a body exactly like Slack does
function signedHeaders(body: string, timestamp = now) {
const signature = "v0=" + createHmac("sha256", secret).update(`v0:${timestamp}:${body}`).digest("hex");
return { "x-slack-request-timestamp": String(timestamp), "x-slack-signature": signature };
}

beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(now * 1000);
});

describe("isFromSlack", () => {
it("accepts a request signed by Slack", () => {
expect(isFromSlack(body, signedHeaders(body), secret)).toBe(true);
});

it("rejects a tampered body", () => {
expect(isFromSlack("text=drop+all+tables", signedHeaders(body), secret)).toBe(false);
});

it("rejects a replayed request", () => {
expect(isFromSlack(body, signedHeaders(body, now - 301), secret)).toBe(false);
});

it("rejects a request without Slack headers", () => {
expect(isFromSlack(body, {}, secret)).toBe(false);
});
});

Create the adapter

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// src/slack/adapter.ts
import type { LambdaFunctionURLEvent } from "aws-lambda";
import { InvokeCommand, LambdaClient } from "@aws-sdk/client-lambda";
import { getParameter } from "@aws-lambda-powertools/parameters/ssm";
import { isFromSlack } from "./verify";

const lambda = new LambdaClient();

export const handler = async (event: LambdaFunctionURLEvent) => {
// The body may arrive base64-encoded. Decode it, but keep it raw: the signature covers these exact bytes
const body = Buffer.from(event.body ?? "", event.isBase64Encoded ? "base64" : "utf8").toString();

// 1. Verify
const secret = await getParameter(process.env.SIGNING_SECRET_PARAM!, { decrypt: true });
if (!secret || !isFromSlack(body, event.headers, secret)) {
return { statusCode: 401 };
}

const { text, response_url } = Object.fromEntries(new URLSearchParams(body));
if (!text) {
return { response_type: "ephemeral", text: "Try: /ask-ops who owns account 123456789012?" };
}

// 2. Hand off. "Event" means fire and forget: Lambda queues the job and returns immediately
await lambda.send(new InvokeCommand({
FunctionName: process.env.WORKER_FUNCTION_NAME!,
InvocationType: "Event",
Payload: JSON.stringify({ question: text, responseUrl: response_url }),
}));

// 3. Acknowledge. No statusCode needed: the Function URL returns this object as JSON with a 200
return { response_type: "ephemeral", text: ":mag: Checking the evidence..." };
};
Three things keep this short:
  • getParameter from Powertools for AWS Lambda  reads the secret from SSM Parameter Store and caches it. No SDK boilerplate.
  • InvocationType: "Event" returns as soon as Lambda has queued the worker, not when the worker finishes.
  • No statusCode on success. When a Function URL handler returns plain JSON, Lambda sends it as a 200 with application/json, which is exactly what Slack expects.

The worker: the contract gets checked twice

In Part 1 the agent validates its own output against GroundedAnswer. In Slack the answer crosses a trust boundary: it leaves the agent and lands in front of people who will act on it. So the worker checks the contract again before posting anything.
If someone later swaps the model or adds a tool that skips validation, the Slack side still needs to be secure. The contract is enforced where the answer is consumed, not only where it's created
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// src/slack/worker.ts
import { BedrockAgentCoreClient, InvokeAgentRuntimeCommand } from "@aws-sdk/client-bedrock-agentcore";
import { GroundedAnswer } from "../agent/contract"; // the Zod schema from Part 1
import { answerMessage, refusal } from "./messages";

const agentCore = new BedrockAgentCoreClient();

type Job = { question: string; responseUrl: string };

export const handler = async ({ question, responseUrl }: Job) => {
const reply = await askAgent(question).catch((error) => {
console.error(error);
return refusal("Something broke on my side. The worker logs have the details.");
});

await fetch(responseUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(reply),
});
};

async function askAgent(question: string) {
const response = await agentCore.send(new InvokeAgentRuntimeCommand({
agentRuntimeArn: process.env.AGENT_RUNTIME_ARN!,
payload: JSON.stringify({ prompt: question }),
}));
const output = JSON.parse(await response.response!.transformToString());

// The evidence contract below
const result = GroundedAnswer.safeParse(output);
if (!result.success) {
console.warn("Contract violation:", result.error.issues);
return refusal("I couldn't ground that answer in evidence, so I won't guess.");
}

return answerMessage(question, result.data);
}
No session ID in sight: the SDK generates a fresh one for every call, so each question starts a clean session.
Look at what happens on failure: the bot refuses rather than posting whatever the model said. "I don't know" is a valid ops answer. A confident wrong answer about who owns a production account is not.
Match this to your Part 1 code: I'm assuming the runtime takes { prompt } and returns the GroundedAnswer JSON directly. If your entrypoint wraps the result (for example { result: ... }), unwrap it before safeParse.

The Slack message: show the evidence, not just the answer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// src/slack/messages.ts
import type { GroundedAnswer } from "../agent/contract";

// plain_text, not mrkdwn, so model output can't sneak in links or @channel pings
export function answerMessage(question: string, { answer, evidence }: GroundedAnswer) {
const sources = evidence.map((e) => e.source).join(", ");

return {
response_type: "in_channel",
text: "Answer from the ops agent", // fallback for notifications
blocks: [
{ type: "context", elements: [{ type: "plain_text", text: `Q: ${question}` }] },
{ type: "section", text: { type: "plain_text", text: answer.slice(0, 3000) } }, // Slack's limit
{ type: "context", elements: [{ type: "plain_text", text: `Sources: ${sources}` }] },
],
};
}

// Ephemeral: only the person who asked sees it
export function refusal(reason: string) {
return { response_type: "ephemeral", text: `:warning: ${reason}` };
}
The question goes on top, the answer in the middle and the sources underneath, so anyone in the channel can check the evidence without asking.
Why plain_text? The agent reads documents, and a document can contain <!channel>. In mrkdwn that pings everyone. In plain_text it's just text.
Field names: I use answer and evidence[].source, and import a GroundedAnswer type (export type GroundedAnswer = z.infer<typeof GroundedAnswer>). Rename to match your Part 1 schema.

Terraform it

One file or many?

Terraform reads every .tf file in a folder and treats them as one configuration. There are no imports, and the order of the files doesn't matter. Splitting is purely for the humans reading it.
I follow HashiCorp's style guide : the standard setup files, plus one file per component, so each file matches a box in the diagram.
1
2
3
4
5
6
7
8
infra/
terraform.tf Terraform and provider versions
providers.tf region and default tags
variables.tf the two inputs
secret.tf the signing secret in SSM
adapter.tf adapter Lambda + its public URL
worker.tf worker Lambda
outputs.tf the URL you paste into Slack
Give it its own folder and its own state, separate from the Part 1 Terraform. Then you can tear down the Slack bot without touching the agent.

The setup files

terraform.tf pins the versions. Write-only arguments need Terraform 1.11, and invoked_via_function_url arrived in AWS provider 6.28.
1
2
3
4
5
6
7
8
9
10
# infra/terraform.tf
terraform {
required_version = ">= 1.11" # write-only arguments need 1.11+

required_providers {
# 6.28+ for invoked_via_function_url
aws = { source = "hashicorp/aws", version = "~> 6.28" }
archive = { source = "hashicorp/archive", version = "~> 2.0" }
}
}
providers.tf has one trick: it reads the region straight out of the agent's ARN, so the Lambdas always land next to the agent. The default tags label every resource with the project, so once you activate Project as a cost allocation tag you can filter the bot's spend in Cost Explorer. Fitting, for a FinOps agent.
1
2
3
4
5
6
7
8
9
10
11
# infra/providers.tf
# Deploy next to the Part 1 agent. The region is the 4th field of its ARN:
# arn:aws:bedrock-agentcore:<region>:<account>:runtime/<id>
provider "aws" {
region = split(":", var.agent_runtime_arn)[3]

# Tag everything, so Cost Explorer (and the ops agent itself) can attribute the spend
default_tags {
tags = { Project = "ops-agent", Component = "slack" }
}
}
1
2
3
4
5
6
7
8
9
10
11
# infra/variables.tf
variable "agent_runtime_arn" {
type = string
description = "ARN of the AgentCore Runtime from Part 1"
}

variable "slack_signing_secret" {
type = string
description = "Signing Secret from the Slack app's Basic Information page"
ephemeral = true # never written to the plan or the state file
}
1
2
3
4
5
# infra/outputs.tf
output "slack_request_url" {
description = "Paste this into the /ask-ops slash command's Request URL"
value = aws_lambda_function_url.adapter.function_url
}

The secret never touches the state file

1
2
3
4
5
6
7
# infra/secret.tf
resource "aws_ssm_parameter" "signing_secret" {
name = "/ops-agent/slack/signing-secret"
type = "SecureString"
value_wo = var.slack_signing_secret # write-only: sent to AWS, never stored in state
value_wo_version = 1 # bump this to rotate the secret
}
value_wo is a write-only argument: Terraform sends the value to AWS and then forgets it. Together with the ephemeral variable, the secret stays out of both the plan and the state file, with no CLI workaround.

The two Lambdas

Both files have the same three parts: Code (the zip and the function), Trigger (how it gets called) and Permissions (what it may do). Once you've read one, you can read the other.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
# infra/adapter.tf
# The front door: verifies Slack, hands off to the worker, replies in < 3 s

# --- Code ---

data "archive_file" "adapter" {
type = "zip"
source_file = "${path.module}/../dist/adapter/index.js"
output_path = "${path.module}/../dist/adapter.zip"
}

resource "aws_lambda_function" "adapter" {
function_name = "ops-agent-slack-adapter"
role = aws_iam_role.adapter.arn
runtime = "nodejs24.x"
handler = "index.handler"
filename = data.archive_file.adapter.output_path
source_code_hash = data.archive_file.adapter.output_base64sha256
timeout = 3 # Slack won't wait longer, so neither do we

environment {
variables = {
SIGNING_SECRET_PARAM = aws_ssm_parameter.signing_secret.name
WORKER_FUNCTION_NAME = aws_lambda_function.worker.function_name
}
}
}

# --- Trigger: a public URL ---

# Auth NONE because Slack can't sign AWS requests. The HMAC check in verify.ts is the auth.
resource "aws_lambda_function_url" "adapter" {
function_name = aws_lambda_function.adapter.function_name
authorization_type = "NONE"
}

# Since October 2025 a public URL needs BOTH permissions below. Miss the second: 403.
resource "aws_lambda_permission" "url" {
statement_id = "AllowPublicFunctionUrl"
action = "lambda:InvokeFunctionUrl"
function_name = aws_lambda_function.adapter.function_name
principal = "*"
function_url_auth_type = "NONE"
}

resource "aws_lambda_permission" "url_invoke" {
statement_id = "AllowInvokeViaFunctionUrl"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.adapter.function_name
principal = "*"
invoked_via_function_url = true # only through the URL, never a direct Invoke
}

# --- Permissions: read the secret, start the worker. Nothing else. ---

resource "aws_iam_role" "adapter" {
name = "ops-agent-slack-adapter"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{ Effect = "Allow", Action = "sts:AssumeRole", Principal = { Service = "lambda.amazonaws.com" } }]
})
}

resource "aws_iam_role_policy_attachment" "adapter_logs" {
role = aws_iam_role.adapter.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}

resource "aws_iam_role_policy" "adapter" {
role = aws_iam_role.adapter.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{ Sid = "ReadSigningSecret", Effect = "Allow", Action = "ssm:GetParameter", Resource = aws_ssm_parameter.signing_secret.arn },
{ Sid = "HandOffToWorker", Effect = "Allow", Action = "lambda:InvokeFunction", Resource = aws_lambda_function.worker.arn },
]
})
}
The trigger part is the one that catches people out. Since October 2025, a public Function URL needs two permissions: lambda:InvokeFunctionUrl and lambda:InvokeFunction. The console adds both for you, which is why it "works in the console" and then fails with a 403 from Terraform. invoked_via_function_url = true limits the second permission to calls that come through the URL.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
# infra/worker.tf
# The slow part: calls the agent, checks the contract, replies via response_url

# --- Code ---

data "archive_file" "worker" {
type = "zip"
source_file = "${path.module}/../dist/worker/index.js"
output_path = "${path.module}/../dist/worker.zip"
}

resource "aws_lambda_function" "worker" {
function_name = "ops-agent-slack-worker"
role = aws_iam_role.worker.arn
runtime = "nodejs24.x"
handler = "index.handler"
filename = data.archive_file.worker.output_path
source_code_hash = data.archive_file.worker.output_base64sha256
timeout = 120 # the agent can take a while; response_url stays valid for 30 minutes

environment {
variables = {
AGENT_RUNTIME_ARN = var.agent_runtime_arn
}
}
}

# --- Trigger: async invoke from the adapter ---

# Async invokes retry twice by default. Here a retry means a duplicate answer in the channel.
resource "aws_lambda_function_event_invoke_config" "worker" {
function_name = aws_lambda_function.worker.function_name
maximum_retry_attempts = 0
}

# --- Permissions: call the agent. Nothing else. ---

resource "aws_iam_role" "worker" {
name = "ops-agent-slack-worker"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{ Effect = "Allow", Action = "sts:AssumeRole", Principal = { Service = "lambda.amazonaws.com" } }]
})
}

resource "aws_iam_role_policy_attachment" "worker_logs" {
role = aws_iam_role.worker.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}

resource "aws_iam_role_policy" "worker" {
role = aws_iam_role.worker.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{ Sid = "InvokeOpsAgent", Effect = "Allow", Action = "bedrock-agentcore:InvokeAgentRuntime", Resource = [var.agent_runtime_arn, "${var.agent_runtime_arn}/*"] },
]
})
}
Two details in the worker: maximum_retry_attempts = 0, because a retried async invoke would post a duplicate answer, and a policy with exactly one statement. The adapter can't call the agent, and the worker can't read the secret.
The parameter uses the AWS managed aws/ssm key, so no kms:Decrypt statement is needed. Switch to a customer managed key and you'll need one.

Ship it

Bundle each handler into a single file. The AWS SDK clients go in the bundle too, because the Lambda runtime's built-in SDK may not include @aws-sdk/client-bedrock-agentcore yet.
1
2
npx esbuild src/slack/adapter.ts --bundle --platform=node --target=node24 --outfile=dist/adapter/index.js
npx esbuild src/slack/worker.ts --bundle --platform=node --target=node24 --outfile=dist/worker/index.js
Then deploy from the infra folder:
1
2
3
4
5
cd infra
export TF_VAR_slack_signing_secret='<signing secret from the Slack app>'
terraform init
terraform apply -var agent_runtime_arn='<runtime ARN from Part 1>'
terraform output slack_request_url
Keep the secret exported in that shell: Terraform never stores it, so it needs it on every apply.
Paste that URL into the /ask-ops slash command's Request URL and save.
Before going to Slack, make sure the front door is actually locked:
1
2
3
4
5
curl -i -X POST "$(terraform output -raw slack_request_url)" \
-H "X-Slack-Request-Timestamp: $(date +%s)" \
-H "X-Slack-Signature: v0=nope" \
-d "text=who+owns+everything"
# HTTP/1.1 401 Unauthorized
A 401 is what we want. Now try it for real:
1
/ask-ops why did our Bedrock bill spike last week?
You get an ephemeral ":mag: Checking the evidence..." straight away, followed by the grounded answer in the channel with its sources underneath.

What we didn't build

  • Threads and conversation memory. Every question gets a fresh session. Follow-ups in threads need the Events API, a bot token and session mapping, which is a post of its own.
  • Multi-workspace distribution. That means OAuth, token storage and a lot more surface area. This setup is for one internal workspace.
  • Rate limiting. The worker has no concurrency cap. If your channel is chatty, set reserved_concurrent_executions on the Lambda for Bedrock as a guardrail, so a burst of questions can't run up the Bedrock bill.

What should Part 3 cover? I've opened a poll on my Builder Center profile, so go vote and the winner gets built next .
The full code is in the companion repo: COMING SOON
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article