AWS Builder Center
@agent in Your GitHub Issues, No Runner Required: Webhooks + Step Functions + Bedrock AgentCore

@agent in Your GitHub Issues, No Runner Required: Webhooks + Step Functions + Bedrock AgentCore

Wire GitHub issue and PR comments straight to an agent with an API Gateway webhook and a Step Functions state machine that invokes an Amazon Bedrock AgentCore harness natively — no CI runner, live progress comments, and IAM as the only auth surface.

@agent in Your GitHub Issues, No Runner Required

In an  earlier post  I walked through running an agent in your PRs with  Claude Code Action  on Amazon Bedrock — drop  @claude  in a comment, a GitHub Actions runner spins up, and the agent responds. That flow is great, and if GitHub is your only trigger source it's hard to beat: the runner already has a correctly-scoped, auto-expiring  GITHUB_TOKEN , and you write almost no infrastructure.
But an Actions runner is a GitHub-shaped assumption. The moment you want the same agent to answer a comment on a Jira ticket — or you want a "watch it work" link that points at your own AWS observability instead of a CI log — the runner stops being free. There's no CI system already wired to Jira comments, and the runner's logs live in GitHub, not your account.
This post covers the other path: an API Gateway webhook → Step Functions →  Amazon Bedrock AgentCore  harness pipeline. A GitHub  issue_comment  (or a Jira  comment_created ) hits an HTTP endpoint, a state machine posts a live-progress comment, invokes the agent, and posts the final answer. No ephemeral runner, the agent turn runs as a native Step Functions integration with zero glue code, and every hop is authorized with IAM.
The two flows are complementary, not competing. Run them side by side with distinct trigger phrases and retire the runner later if you ever want to — that's a decision you can defer.

The shape of it

Plain text
GitHub issue_comment webhook Jira comment_created webhook
(X-Hub-Signature-256 HMAC) (?source=jira&secret=<shared secret>)
│ │
└──────────────┬───────────────────────┘
▼
API Gateway HTTP API POST /webhook
│ (Lambda REQUEST authorizer: signature-format gate)
▼
webhook-receiver Lambda
• verifies the signature/secret per source
• detects the @agent mention OR the "agent" label
• ignores bot senders (loop prevention)
• StartExecution → returns 202 immediately
│
▼
Step Functions (STANDARD)
1. post-comment (initial) → live-progress link
2. prep (Lambda) → context + git auth
3. InvokeHarness (NATIVE bedrockagentcore:invokeHarness)
4. post-comment (final) → the agent's answer
Four states, one of which — the actual agent turn — is a native AWS service integration with no Lambda behind it. Let's walk the interesting parts.

Why a webhook beats a runner here

Two reasons, both concrete:
  1. Jira has no runner. GitHub Actions is a CI system that already listens to repo events. Jira has nothing equivalent wired to comments, so a webhook receiver is the only option there. Once you're building a receiver for Jira, pointing GitHub at the same endpoint costs you nothing extra.
  1. The "watch it work" link points at your account. The initial comment can carry an  Amazon CloudWatch Logs Live Tail  deep-link (or a link to your own chat UI) so a user watches the run stream in the AWS console. That's a materially different experience than a link into a CI job's log.
To keep both paths from firing on the same GitHub comment when you run them together, give them distinct trigger phrases — e.g. the Actions flow matches  @agent , this pipeline matches  @agentcore .

Verifying the webhook: two sources, two mechanisms

GitHub and Jira don't authenticate webhooks the same way, so the receiver branches on a  source  marker.
Source
Mechanism
Where
GitHub
HMAC-SHA256 over the raw body, keyed by the webhook secret
 X-Hub-Signature-256: sha256=<hex>  header
Jira
Shared secret, constant-time compared
 ?source=jira&secret=<secret>  query param
GitHub signs every delivery — you recompute the HMAC over the exact bytes received and compare with  crypto.timingSafeEqual .  GitHub's webhook-security docs  spell out the scheme.
Jira Cloud's classic REST-registered webhooks have no signing scheme equivalent to GitHub's — Atlassian only signs deliveries for full  Connect/Forge app installations , which is a much heavier integration than a comment trigger needs. The standard workaround is a shared secret passed as a query parameter and compared in constant time (never logged). If you later add a full Connect/Forge app, switch to its signed-JWT scheme.
Store both secrets in  AWS Secrets Manager  and pass the ARNs as deploy-time inputs — the receiver reads them at invoke time.

A REQUEST authorizer as a fast format gate

An API Gateway route with  AuthorizationType: NONE  is an easy AppSec finding to trip over. The fix here is a  Lambda REQUEST authorizer  in front of  POST /webhook  — but with an important caveat about what it can't do.
Lambda authorizers never see the request body — only headers, query string, and request context. GitHub's HMAC is computed over the raw body, so the real cryptographic check has to stay in the receiver, where the body is available. The authorizer is a cheap signature-format gate: for requests carrying  X-Hub-Signature-256 , it requires the header to match  ^sha256=[0-9a-f]{64}$  and rejects obviously-malformed calls before they reach the receiver. Jira deliveries (no such header) pass through, since their auth is the  ?secret=  param checked downstream.
There's a subtle trap in the identity-source config. Per the  HTTP API Lambda-authorizer docs : "If you specify identity sources, clients must include them in the request. If the client's request doesn't include the identity sources, API Gateway doesn't invoke your Lambda authorizer, and the client receives a  401  error." If you named  X-Hub-Signature-256  as the identity source, every Jira delivery would 401 outright — Jira never sends that header. So use  $context.routeKey  (always present) as the identity source and let the Lambda do the GitHub-vs-Jira branch itself.
Two more details: use the  simple boolean response  format ( { "isAuthorized": true|false } ), and disable result caching ( resultsCacheTtl: 0 ) — every webhook delivery has a unique signature, so a cached allow/deny would be wrong.

The star of the show: a native AgentCore harness invoke

Here's the part that makes this pipeline pleasant to own. The agent turn is not a Lambda that hand-rolls an event-stream decoder. It's the native Step Functions optimized integration  arn:aws:states:::bedrockagentcore:invokeHarness  —  documented here . In Workflow Studio you search for AgentCore InvokeHarness and drag it in.
A minimal Task state looks like this:
JSON
{
"Type": "Task",
"Resource": "arn:aws:states:::bedrockagentcore:invokeHarness",
"Arguments": {
"HarnessArn": "arn:aws:bedrock-agentcore:us-east-1:123456789012:harness/my-agent-harness",
"RuntimeSessionId": "{% $states.input.runId %}",
"Messages": [
{ "Role": "user", "Content": [{ "Text": "{% $states.input.prompt %}" }] }
],
"Model": {
"BedrockModelConfig": {
"ModelId": "global.anthropic.claude-sonnet-4-6",
"Temperature": 0.7
}
},
"MaxIterations": 75,
"TimeoutSeconds": 600
},
"End": true
}
The integration decodes the harness's streamed response into a  Converse -shaped result. A few properties are worth internalizing before you build on it, straight from the docs:
  • Request-Response only. The  .sync  and task-token patterns aren't supported — which is fine, you want the reply inline.
  • Only the final assistant message comes back. Earlier turns, tool-use, and reasoning blocks are dropped from  Output.Message.Content . That's exactly what you post to the issue.
  •  Content  can be an empty array. When a turn ends on a tool action ( StopReason  such as  tool_use ), the integration omits tool-use blocks, so  Content  is  [] . Do not JSONPath into  Content[0].Text  — that crashes the state on an empty array. Join the text blocks of the whole array instead, and post a friendly "no text response" note when it's empty.
  • 15-minute hard cap on the task regardless of  TimeoutSeconds ; set the harness's own timeout under 15 minutes too, or it keeps running (and billing) after the Task state times out.
  • Output is bounded by the 256 KB Task-state output quota — very long replies are truncated by that limit.
And the governance win: the task is signed with the state machine's execution role, not a Lambda's. You grant  bedrock-agentcore:InvokeHarness  (plus  InvokeAgentRuntime ) on the harness ARN to the state machine role, and that's the whole auth story for the agent turn — no code, no stored credentials:
JSON
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": [
"bedrock-agentcore:InvokeHarness",
"bedrock-agentcore:InvokeAgentRuntime"
],
"Resource": "arn:aws:bedrock-agentcore:us-east-1:123456789012:harness/my-agent-harness"
}]
}
Naming gotcha: the Step Functions resource URI uses  bedrockagentcore  (no hyphen), while the service ARN uses  bedrock-agentcore  (with hyphen). Easy to fat-finger.
Enable  CloudWatch Transaction Search  and the Step Functions console shows a per-turn CloudWatch link beside the InvokeHarness step — a turn-by-turn view of the agent's reasoning and tool use that the returned message alone doesn't carry.

Giving the agent write access: harness exec in the same session

Posting a comment is read-mostly; opening a PR needs the agent to  git clone , commit, push, and run  gh pr create . The AgentCore harness runtime has a shell but no workspace-prep step, so you seed credentials into the same runtime session the agent will use, before the native invoke.
The prep Lambda (state 2) does this via the harness-exec API —  InvokeAgentRuntimeCommand , i.e.  POST /runtimes/{harnessArn}/commands , SigV4-signed. Two things that cost real debugging time if you get them wrong:
  • Call the harness ARN, not the backing runtime ARN. A  CfnHarness  exposes its backing runtime ARN, but hitting that ARN's exec endpoint directly returns HTTP 400 — "managed by a harness and cannot be invoked directly. Use the InvokeAgentRuntimeCommand API with the relevant harness ID instead."
  • Reuse the same  RuntimeSessionId  across the exec and the invoke (use the Step Functions run ID for both) so credentials the exec seeds land in the same container the native invoke then uses. Verified empirically: a marker file written by the exec is readable by the agent's code-interpreter tool in the same session.
The exec command configures git identity, writes a credential-store helper seeded with a short-lived  GitHub App installation token , and — because the Amazon Linux 2023 harness image ships  git  but not  gh , and  gh  isn't in the AL2023  dnf  repos — installs  gh  from its official release tarball. A neat trick there: the image has no  tar  either, but it does have  python3 , so you extract with Python's stdlib:
Bash
python3 -c "import tarfile; tarfile.open('gh.tar.gz').extractall('.')"
The token travels once in the TLS-encrypted exec request body and lives only in the session's  ~/.git-credentials  and  gh  config — it's never surfaced to the model, and the agent's later tool calls never receive it. The prep Lambda then annotates the prompt with a short block telling the agent that  git  and  gh  are already authenticated for the target repo, so it opens PRs directly instead of assuming it lacks write access.
Why keep this a Lambda when the invoke went native? The exec API has no optimized Step Functions integration, and it returns an event stream whose exit code you have to read — a generic AWS-SDK task can't consume that. The Lambda also captures the exec's stdout/stderr into CloudWatch, which you'll want the first time a clone or push fails.

Loop prevention and label triggers

Two small but load-bearing details:
  • Loop prevention. The final comment the pipeline posts must not re-trigger the pipeline. On GitHub, ignore senders where  sender.type === 'Bot'  or the login ends in  [bot] . Jira has no bot-sender concept, but since the poster's comment doesn't itself contain the trigger phrase, there's no loop.
  • Label triggers as a bonus. Beyond the  @agentcore  mention, you can start the same state machine when an  agentcore  label is applied to an issue/PR (handle  issues / pull_request  events with  action == "labeled" ). Add a  agent-working  label at the start and swap it for  agent-error  on the failure path — all best-effort so a label API hiccup logs a warning but never fails the run.

Wiring the state machine

The state machine is a four-state chain:  LambdaInvoke  (post initial) →  LambdaInvoke  (prep) →  CustomState  (native  bedrockagentcore:invokeHarness ) →  LambdaInvoke  (post final), with a  Catch  on the prep and invoke states routing to a failure-comment state so users always get something back. State I/O threads through with JSONPath ( $.prepared ,  $.agentResult ,  $.error ).
One deployment lesson worth stealing: provision the webhook stack separately from the stack that owns your agent, and grant the receiver's  states:StartExecution  against a plain-string state-machine ARN rather than a cross-stack CloudFormation reference. The state machine's  LambdaInvoke  tasks depend on the function stack for ARNs; if the function stack also depends on the state machine's ARN token, you get a circular nested-stack dependency at synth time. A plain-string ARN ( arn:aws:states:<region>:<account>:stateMachine:<name> ) breaks the cycle.

Takeaways

  • The native  bedrockagentcore:invokeHarness  integration is the payoff. No event-stream decoding, no invoke Lambda, and the agent turn is authorized by the state machine role against the harness ARN — IAM all the way down.
  • Know the invoke contract before you build: Request-Response only, final message only,  Content  can be  []  (don't index  [0] ), 15-minute cap, 256 KB output.
  • A Lambda authorizer can only gate signature format — the HMAC-over-raw-body check has to live where the body is. Use  $context.routeKey  as the identity source so multi-source routes don't 401 the unsigned source.
  • Seed git/gh credentials into the harness session via  InvokeAgentRuntimeCommand  against the harness ARN, reusing the run's session ID, and keep the token out of the model's context.
  • Run it alongside the Actions flow, not instead — distinct trigger phrases, and retire the runner later only if you want to.
If the CI-runner path fits your world better, the  Claude Code Action on Bedrock walkthrough  is the companion piece. And once your agent is running in either place, you'll want to watch it work — I covered  streaming agent telemetry to CloudWatch and rebuilding the conversation  in a separate post, which pairs naturally with the Live Tail link this pipeline posts.

Related reading

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