Skip to content

Deploy preview screenshots guide

Wire your repo into ABCA so that every preview deploy gets screenshotted and posted as a comment on the open GitHub PR. For Jira-origin tasks, the originating issue receives screenshot and live-preview links. Linked Linear issues also receive preview feedback when configured.

The pipeline only needs GitHub. Linear posting is opt-in: present iff LinearWorkspaceRegistryTable has at least one active row (configured via Linear setup guide). Without Linear, the GitHub-side screenshot still works; the Linear-side just no-ops silently.

The pipeline accepts GitHub deployment_status events and successful AWS Amplify PR preview check_run events. Providers that call the GitHub Deployments API work through the deployment-status path:

ProviderOut of the box?Notes
Vercel (managed hosting + GitHub app)✅The worked example below uses this. Default environment is Preview.
AWS Amplify Hosting (Connected to GitHub)✅Enable PR previews and subscribe the ABCA webhook to Check runs. Successful AWS Amplify Console Web Preview checks bypass the deployment environment filter.
Netlify (managed hosting + GitHub app)⚠environment is Deploy Preview <PR#>, which the current single-string SCREENSHOT_TARGET_ENVIRONMENT filter doesn’t match across all PRs. Workable today only by picking one specific PR’s environment string; broader pattern matching isn’t shipped.
GitHub Actions that calls POST /repos/.../deployments (typical for ECS/Fargate, Cloud Run, Fly.io, Railway, Cloudflare Pages, etc.)✅Your workflow controls the environment field; pass whatever you want and set SCREENSHOT_TARGET_ENVIRONMENT to match.
External CI (CircleCI, GitLab, ArgoCD) that doesn’t touch GitHub Deployments❌Add a final job that calls the GitHub Deployments API after the deploy succeeds — see GitHub’s example.

For a deployment-status event, ABCA needs:

  1. The deployment_status event has reached state: success.
  2. deployment_status.environment_url is populated with the live preview URL.

If your provider gives you that, you’re done. The example below is Vercel because that’s what we smoke-tested on; the pipeline doesn’t otherwise prefer one provider over another.

For Amplify, enable Hosting → Previews on the PR’s target branch and add Check runs to the repository’s ABCA webhook events. Amplify publishes the URL in the completed check’s details_url; a green GitHub check alone will not trigger capture if the webhook only subscribes to Deployment statuses. ABCA accepts successful preview checks from the aws-amplify-console app owner with a matching PR number, head SHA, and HTTPS pr-<number>.<app-id>.amplifyapp.com URL. No manual deployment event or extra GitHub Actions workflow is needed. The processor fetches that exact PR and confirms it is still open with the same head SHA before capture, so two PRs sharing a commit cannot redirect the screenshot or Jira/Linear feedback.

Existing Amplify operators: redeploy ABCA to pick up this receiver and processor, then add Check runs to your existing webhook while keeping Deployment statuses selected. Keep any branch-name SCREENSHOT_TARGET_ENVIRONMENT value (for example, main): deployment statuses still use it, while validated Amplify PR checks bypass it. After redeploy, a webhook already subscribed to Check runs will start capturing and publishing Amplify PR previews that a branch-name filter previously excluded, with no further configuration change; screenshots are publicly readable through CloudFront and may contain customer data rendered by the preview. To keep Amplify check-triggered capture disabled, deselect Check runs on the ABCA webhook. You do not need to change the environment filter to Preview. Subscriptions affect future events only; rebuild an existing PR preview to verify the change. If an earlier receiver already accepted and deduplicated a completion, replaying that same check within the one-hour dedup window will not capture again.

When you (or the agent) push to a branch that triggers a preview deploy, your provider deploys the preview, posts a deployment status or Amplify preview check back to GitHub, and ABCA’s webhook receiver:

  1. Captures a full-page screenshot of the preview URL via AgentCore Browser
  2. Uploads the PNG to a private S3 bucket served via CloudFront
  3. Posts a markdown image comment on the open GitHub PR
  4. Posts explicit screenshot/live-preview links to the originating Jira issue, or delivers the preview to Linear when configured. Iterations preserve the preview in their existing status comment.

End-to-end latency: typically 10–15 seconds after your provider reports the deploy.

agent push → provider preview build → deployment_status / Amplify check_run
↓
POST /v1/github/webhook
↓
receiver Lambda (HMAC verify, dedup,
successful deploy/check +
provider validation)
↓
processor Lambda
↓
AgentCore Browser session
↓
PNG → private S3 (30-day TTL)
↓
CloudFront-served public URL
↓
GitHub PR comment (+ Jira/Linear feedback if linked)

Architecture notes:

  • Lambda-only. No agent runtime is involved post-PR — the screenshot job is deterministic; an LLM would only add cost without changing behavior.
  • AWS-managed default browser. AgentCore Browser ships an aws.browser.v1 session you can attach to without provisioning your own browser resource.
  • Private S3 + CloudFront with OAC. Screenshot bucket is fully private; CloudFront serves images anonymously over HTTPS so GitHub markdown image embeds (and Linear’s, when configured) can render them without auth.
  • WAF exemption. The /v1/github/webhook path is exempted from the SizeRestrictions_BODY rule in AWSManagedRulesCommonRuleSet because the full deployment_status payload (workflow run history + deploy URLs + deployment metadata) exceeds the 8 KB body-size limit. All other CRS rules (LFI, RFI, XSS, SQLi, …) still evaluate against the path; HMAC verification in Lambda authenticates the body.
  • Skips non-2xx pages. The processor enables CDP’s Network domain and captures the main-document HTTP status. If the preview URL returns 4xx/5xx (404 / 503 / a 3xx that doesn’t redirect cleanly), the processor logs Preview URL returned HTTP <status>; skipping screenshot and posts no PR/Linear comment. This avoids posting a confidently-wrong screenshot of a 404 page as if it were the deploy. Auth walls that return HTTP 200 (e.g. Vercel deployment protection) are out of scope — disable deployment protection or use a public preview, see the Vercel setup section below.
  • ABCA stack deployed (mise //cdk:deploy) — confirm GitHubWebhookUrl + GitHubWebhookSecretArn + ScreenshotCloudFrontDomain are listed in the stack outputs
  • (Optional) Linear OAuth installed for at least one workspace (bgagent linear setup <slug>) — only required if you want screenshots posted to Linear issues in addition to the GitHub PR
  • A GitHub repo you own
  • Your deploy provider connected to that repo (the example uses Vercel)
  • AWS CLI logged in to the same account as the ABCA stack
  • The bgagent CLI installed (bgagent configure, bgagent login)

Step 1 — Connect Vercel to your GitHub repo

Section titled “Step 1 — Connect Vercel to your GitHub repo”
  1. Open https://vercel.com/dashboard.
  2. Add New → Project.
  3. Find your repo in the list. If it’s not visible, click “Adjust GitHub App Permissions” and grant access.
  4. Click Import.
  5. Accept the framework defaults — Vercel auto-detects most stacks.
  6. Click Deploy. Wait for the first deploy to finish.

Go to your-project → Settings in the Vercel dashboard.

  • Connected Git Repository: confirm the repo is listed.
  • deployment_status Events: toggle Enabled (this is what tells Vercel to post the webhook to GitHub when each deploy finishes).
  • Pull Request Comments: optional — Vercel’s own comment with the preview URL. Doesn’t affect ABCA either way.
  • Vercel Authentication: set to Disabled (or “Only Production Deployments”) for the demo. Otherwise AgentCore Browser will hit a Vercel auth wall and screenshot the login page instead of your app.

Production hardening. Real deployments should keep Vercel Authentication on Standard Protection and use a signed bypass token. The screenshot processor would need to inject the bypass token as a query parameter on the preview URL it navigates to — currently not implemented.

Using Amplify? Enable PR previews under Hosting → Previews, then follow Step 3 and include Check runs in the webhook events. For other providers, publish successful deployment_status events to GitHub. For self-hosted CI, add a GitHub Deployments API call at the end of your deploy job.

This wires deploys back to ABCA’s screenshot pipeline.

Terminal window
bgagent github webhook-info

The CLI prints the webhook URL and the values to paste into GitHub.

  1. Open https://github.com/<your-org>/<your-repo>/settings/hooks.
  2. Click Add webhook.
  3. Fill in the values printed by webhook-info:
    • Payload URL: the URL it printed
    • Content type: application/json
    • Secret: generate any random string — paste it both here AND into the next step
    • SSL verification: leave enabled
    • Which events?: choose “Let me select individual events”, uncheck Pushes, check Deployment statuses, and also check Check runs for AWS Amplify PR previews
    • Active: ✓
  4. Add webhook. GitHub fires a ping event right away — under “Recent Deliveries” you should see ✅ within seconds.
Terminal window
bgagent github set-webhook-secret

Paste the same secret you generated in 3b. The CLI writes it to the stack’s GitHubWebhookSecret Secrets Manager entry, where the receiver Lambda reads it for HMAC verification.

Open any PR on the configured repo (push a commit, open a PR however you normally do — GitHub UI, gh pr create, GitHub Actions, agent, etc.) Wait 2–5 minutes for your provider to build the preview. The screenshot should land on the PR as a markdown image comment.

If you also have Linear configured: create a Linear issue in a mapped project (e.g. “Update homepage heading”), apply the trigger label, and watch the agent open a PR. The same screenshot lands on both the GitHub PR and the Linear issue. If the GitHub comment shows but Linear doesn’t, see Troubleshooting.

The pipeline filters deployment_status webhooks against SCREENSHOT_TARGET_ENVIRONMENT (default Preview, matches Vercel’s per-PR environment label). To use a different value, pass screenshotTargetEnvironment to the GitHubScreenshotIntegration construct in your CDK app and redeploy.

ProviderTypical environment valueWhat to set
VercelPreviewleave default
Amplify Hosting PR checknot used for filteringkeep existing value; subscribe to Check runs
Amplify branch deployment statusbranch namematch the branch name exactly
NetlifyDeploy Preview <PR#>currently not directly matchable across all PRs (single fixed-string filter only)
GitHub Actions customwhatever your workflow passesmatch it exactly

GitHub webhook deliveries return 401 / 403

Section titled “GitHub webhook deliveries return 401 / 403”
  • 401 “Missing signature”: the request didn’t reach our Lambda — check that you saved the webhook with the right signing secret.
  • 401 “Invalid signature”: the secret you pasted into GitHub doesn’t match what’s stored in AWS. Re-run bgagent github set-webhook-secret with the value from the GitHub webhook page.
  • 403 “Forbidden” with X-Amzn-Errortype: ForbiddenException: WAF rejected the body. Should not happen on the /v1/github/webhook path because that path is exempted from the CommonRuleSet, but if you see it, check the BlockedRequests metric on the TaskApiWebAcl regional WebACL in CloudWatch.

Webhook delivers 200 but no screenshot lands

Section titled “Webhook delivers 200 but no screenshot lands”

For Amplify, confirm Check runs is selected on the ABCA webhook, the AWS Amplify Console Web Preview check completed successfully, and its details link opens the PR preview. skipped_check means the event was not an eligible successful Amplify PR preview. Its reason is also logged by the receiver as screenshot.amplify_check_rejected, without the raw payload or URL. A valid X-GitHub-Delivery UUID is logged as delivery_id so you can locate the event in GitHub. Expected incomplete or unrelated checks retain info-level reason logs because every rejected check must be diagnosable. Adding the subscription only affects future events; rebuild an existing preview to exercise the automatic path.

Inspect the receiver logs for rejected checks:

ReasonWhat to check
action_not_completed, check_not_completed, check_not_successfulWait for a successful completed check.
unexpected_check_name, unexpected_app_owner, unexpected_app_slugThe check must be AWS Amplify Console Web Preview, owned by aws-amplify-console, with an aws-amplify-* app slug. Other CI checks are ignored.
invalid_details_url, untrusted_preview_url, invalid_pr_numberThe details link must be a trusted HTTPS pr-N.<app-id>.amplifyapp.com preview URL with a positive PR number, no credentials, and no non-default port.
invalid_pull_requests, preview_pr_not_found, head_sha_mismatchThe check must list the preview PR with the same head SHA as the check.
invalid_payload, invalid_check_id, invalid_head_sha, invalid_repositoryThe webhook payload is malformed; inspect the delivery in GitHub.

Malformed JSON returns 400 and logs screenshot.webhook_rejected with reason: invalid_json. Invalid signatures return 401 before normalization. Valid checks use a separate amplify# dedup namespace, so a deployment-status event with identical IDs does not suppress the check. Duplicate checks return deduped without dispatching another capture.

Check the screenshot processor logs:

Terminal window
aws lambda list-functions --region us-east-1 \
--query "Functions[?contains(FunctionName, 'GitHubScreenshot') && contains(FunctionName, 'Processor')].FunctionName" \
--output text

Then tail the function’s CloudWatch log group. Common silent skips:

  • skipped_state — the delivery was for a non-success status (e.g. pending, in_progress); ignore.
  • skipped_environment (deployment statuses only) — the deploy’s environment field doesn’t match SCREENSHOT_TARGET_ENVIRONMENT. Common cause for non-Vercel providers; see “Configuring for non-Vercel providers” above.
  • skipped_no_url — the success status didn’t include environment_url. Some providers post URL-less success events; the next push usually carries the URL.
  • screenshot.amplify_pr_rejected — invalid forwarded PR metadata or a terminal rejection of the live PR response, logged once at warn level. Capture stops immediately without retrying, falling back to another PR, or emitting SCREENSHOT_PR_LOOKUP_EXHAUSTED. See the reasons below.
  • screenshot.pr_lookup_rejected / SCREENSHOT_PR_LOOKUP_REJECTED — GitHub rejected the validated PR lookup with a permanent HTTP failure, logged once at ERROR with status. Check token access and permissions, including for 404 responses that can conceal inaccessible repositories. Capture stops without retrying.
  • screenshot.pr_lookup_exhausted / SCREENSHOT_PR_LOOKUP_EXHAUSTED — retries or the lookup budget ran out. Check GitHub availability, token permissions, rate limits, or whether a deployment completed before the PR was created.
  • screenshot.preview_url_rejected — URL validation stopped capture. untrusted_preview_url logs at WARN with preview_host and url_parsed; preview_pr_number_mismatch logs at ERROR with SCREENSHOT_PREVIEW_PR_MISMATCH when the preview hostname disagrees with the forwarded PR number.

Processor-side Amplify reasons:

ReasonWhat to check
invalid_forwarded_pr_numberThe forwarded PR number must be a positive safe integer. Deploy the receiver and processor together.
pr_not_foundGitHub returned 404 for the validated PR. Confirm the PR exists and the GitHub token can access it; GitHub also uses 404 to conceal inaccessible resources.
pr_request_rejectedGitHub rejected the PR lookup with a non-retryable 4xx response. Inspect the logged status, token permissions, and request.
pr_number_mismatchGitHub returned a different PR number than the validated preview. Inspect the PR lookup response.
pr_not_openThe PR closed or merged while Amplify built the preview. No capture is needed.
live_pr_head_sha_mismatchThe live PR head changed after the check started. Rebuild the current preview. Receiver code head_sha_mismatch instead compares the check’s embedded PR head with its own SHA.
missing_head_refGitHub returned the expected PR and SHA without a usable branch name. Inspect the PR response.
malformed_pr_responseGitHub returned a null, array, or non-object PR body. Inspect the API response.

The processor rechecks the Amplify URL’s HTTPS origin, credentials, port, and PR number before requesting a token or capturing. URL rejection logs include only the hostname, never credentials, paths, or queries. A hostname/forwarded-number mismatch indicates an inconsistent invocation; inspect the receiver and processor deployment versions.

Fetch failures, timeouts, non-JSON bodies, 5xx responses, HTTP 408/429, and all 403 responses retain bounded retries. Secondary rate limits can omit rate-limit headers, so even an ambiguous 403 retries; persistent permission failures then emit SCREENSHOT_PR_LOOKUP_EXHAUSTED. Deployment-status commit-pulls HTTP failures, including 404, also remain retryable. Exhaustion records the final reason:

ReasonWhat to check
fetch_failedGitHub network reachability or per-request timeout.
http_errorGitHub returned non-2xx responses; inspect the preceding logged HTTP statuses, token permissions, and rate limits.
non_json_responseGitHub’s response could not be parsed as JSON.
malformed_pr_responseThe deployment-status commit-pulls endpoint returned a non-array body.
pr_not_linkedNo open PR is associated with the deployment SHA yet.
budget_exhaustedEarlier processing consumed the PR lookup budget before the first request.

After resolving a failure, rebuild the preview or wait for the one-hour dedup window before replaying the same check.

No screenshots at all: check the processor alarms and DLQ

Section titled “No screenshots at all: check the processor alarms and DLQ”

The receiver Lambda async-invokes the processor (InvocationType: Event) and returns 200 to GitHub as soon as that invoke is accepted, so a processor-side fault never propagates back — GitHub sees success and never redelivers. (Only a failure to even enqueue the invoke returns 500.) Two operator-visible signals catch a hard processor fault that would otherwise stop screenshots silently.

Important: the processor handler is best-effort by design — it catches its own per-step operational failures (bad token, AgentCore/S3/comment-post errors) and returns success, so those show up as tagged log events (see the section above), not as Lambda Errors. Both alarms below fire only on faults that escape the handler: an init-time crash (missing env at cold start, bundling defect), an unhandled throw in an unguarded path, or the 120s hard timeout. For a swallowed operational failure (e.g. a revoked S3/AgentCore permission), watch the processor logs, not these alarms.

  • WebhookProcessorErrorAlarm — fires on the processor’s Lambda Errors metric: any 5-minute period with >= 1 error alarms (evaluation range 2 periods, 1 datapoint to alarm). Early signal: an invocation is faulting now.
  • WebhookProcessorDlqDepthAlarm — fires when such a failed invocation has survived Lambda’s built-in async retries and landed on the DLQ (construct id WebhookProcessorDlq; 14-day retention, SSL-enforced). Backstop: a payload is now parked undelivered. (The queue also gets SSE via SQS’s service-side default; the construct doesn’t set an explicit encryption.)

The two catch the same failure class, but the Errors alarm fires no later than — and, when the retry ladder straddles a period boundary, one window before — the DLQ-depth alarm (the Errors metric is stamped at invocation time, before retries exhaust onto the DLQ). Find and inspect the DLQ — the construct sets no explicit queue name, so its physical name is CloudFormation-generated and contains the WebhookProcessorDlq construct id:

Terminal window
# Find the DLQ URL (physical name contains the construct id)
aws sqs list-queues --region us-east-1 \
--query "QueueUrls[?contains(@, 'WebhookProcessorDlq')]" --output text
# How many failed invocations are parked?
aws sqs get-queue-attributes --region us-east-1 \
--queue-url <DLQ_URL> \
--attribute-names ApproximateNumberOfMessages
# Peek at a parked event (the original async-invoke payload + Lambda error context)
aws sqs receive-message --region us-east-1 \
--queue-url <DLQ_URL> --max-number-of-messages 1 \
--visibility-timeout 0

The message body is the original async-invoke event; the RequestContext/error attributes show why Lambda gave up. Fix the root cause (re-check the processor’s IAM grants, AgentCore Browser quota, and the GitHub/Linear token secrets). The event source is GitHub, not the DLQ, so recovery is to redeliver the webhook from GitHub’s UI (a Lambda async-invoke DLQ has no automatic re-invoke path — the parked messages are event copies for diagnosis). Purge the queue (aws sqs purge-queue) once the alarm has cleared and you no longer need the payloads.

Screenshot lands on GitHub PR but not on Linear

Section titled “Screenshot lands on GitHub PR but not on Linear”

The GitHub-side post is the primary path; Linear is opt-in and best-effort. Skipping the Linear post is normal if you don’t have Linear configured. If you do, look for the processor log line Linear identifier did not resolve to an issue — usually means:

  • The PR title and body don’t contain a Linear-style identifier (e.g. ABCA-42). The agent’s task description includes the identifier by default; if you opened the PR manually it might not.
  • The identifier’s workspace isn’t OAuth-installed. Run bgagent linear list-projects to confirm the issue’s project is in the registry.

Visit the public URL directly:

https://<ScreenshotCloudFrontDomain>/screenshots/<owner>_<repo>/<sha>-<deploymentId>-<16hex>.png

(Copy the exact URL from the PR comment — the <16hex> suffix is random per capture, so you can’t hand-construct it.)

If it 403s, check that the bucket policy includes the OAC service principal (CDK should generate this automatically — re-deploy if it doesn’t).

Screenshot shows a login page (Vercel only)

Section titled “Screenshot shows a login page (Vercel only)”

You forgot Step 2’s “Vercel Authentication: Disabled” toggle. Toggle it off, push another commit, and confirm the next screenshot renders the actual app.

Things to think about before using this on a real product:

  • Deploy protection. This guide turns Vercel Authentication off so the headless browser can render the preview. For real use, you’ll want it back on with a signed bypass token (or your provider’s equivalent) and the bypass injected onto the preview URL the screenshot processor navigates to.
  • IAM scope. The screenshot processor’s IAM is scoped to the three AgentCore Browser actions the handler calls — StartBrowserSession, StopBrowserSession, ConnectBrowserAutomationStream — plus standard Lambda + S3 + Secrets Manager grants. The first two are control-plane writes; the third is the data-plane SigV4-presigned WSS handshake (it’s published in the AWS Service Authorization Reference for bedrock-agentcore, which also notes it takes no resource types or condition keys). Resource is * because Browser sessions are ephemeral and the data-plane stream actions don’t support resource-level scoping. A cdk-nag IAM5 suppression annotates the resource wildcard.
  • SSRF surface. The processor navigates AgentCore Browser to deployment_status.environment_url from the verified webhook payload. The handler validates the URL up front (https only, no literal-IP, no localhost / link-local / loopback) so a forged payload can’t pivot the browser at private hosts. AgentCore Browser also runs outside the customer VPC, so IMDS and private-subnet pivots are neutralized regardless. Stricter operators can add an explicit hostname allowlist by editing isAllowedScreenshotUrl in cdk/src/handlers/shared/screenshot-url.ts.
  • Screenshot URL enumerability. The bucket is private, but CloudFront serves anonymously and the path follows screenshots/<owner>_<repo>/<sha>-<8-byte-random>.png. The 64-bit random suffix makes URLs unguessable for an outside reader (the prefix is enumerable from the public PR; the suffix is not). If your previews regularly render PII or other regulated content, consider also enabling CloudFront access logs + a WAF in front of the CDN and shortening screenshot retention below the 30-day default (constant in cdk/src/constructs/screenshot-bucket.ts).