Custom Safe Outputs

Custom safe outputs extend built-in GitHub operations to third-party services such as Slack, Discord, Notion, databases, or any external API that requires authentication. Use them for write operations that built-in safe outputs do not cover.

Here’s a minimal custom safe output that sends a Slack message:

.github/workflows/shared/slack-notify.md
---
safe-outputs:
jobs:
slack-notify:
description: "Send a message to Slack"
runs-on: ubuntu-latest
output: "Message sent to Slack!"
inputs:
message:
description: "The message to send"
required: true
type: string
steps:
- name: Send Slack message
env:
SLACK_WEBHOOK: "${{ secrets.SLACK_WEBHOOK }}"
run: |
if [ -f "$GH_AW_AGENT_OUTPUT" ]; then
MESSAGE=$(cat "$GH_AW_AGENT_OUTPUT" | jq -r '.items[] | select(.type == "slack_notify") | .message')
# Use jq to safely escape JSON content
PAYLOAD=$(jq -n --arg text "$MESSAGE" '{text: $text}')
curl -X POST "$SLACK_WEBHOOK" \
-H 'Content-Type: application/json' \
-d "$PAYLOAD"
else
echo "No agent output found"
exit 1
fi
---

Use it in a workflow:

.github/workflows/issue-notifier.md
---
on:
issues:
types: [opened]
permissions:
contents: read
imports:
- shared/slack-notify.md
---
# Issue Notifier
A new issue was opened: "${{ steps.sanitized.outputs.text }}"
Summarize the issue and use the slack-notify tool to send a notification.

The agent calls slack-notify, and the custom job runs with access to the SLACK_WEBHOOK secret.

Agents use read-only Model Context Protocol (MCP) servers with allowed: tool lists. Custom jobs perform writes with secret access after the agent completes.

┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Agent (AI) │────│ MCP Server │────│ External API │
│ │ │ (read-only) │ │ (GET requests) │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│
│ calls safe-job tool
▼
┌─────────────────┐ ┌─────────────────┐
│ Custom Job │────│ External API │
│ (with secrets) │ │ (POST/PUT) │
└─────────────────┘ └─────────────────┘

Optionally map status and url outputs from the job so Conclusion can record what happened:

outputs:
status: ${{ steps.publish.outputs.status }}
url: ${{ steps.publish.outputs.url }}

The step with id: publish sets them with $GITHUB_OUTPUT or core.setOutput. status is success, failure, cancelled, or skipped. Use skipped for staged previews and intentional no-ops. Set url only to the absolute URI the service returns, never one the agent suggested.

Conclusion records a safe_output.result event in usage/aw_session.jsonl. Without an explicit status, the Actions job result is used. Failed, cancelled, and skipped jobs never report a URL. A successful job without a URL proves execution, not creation of an identifiable resource. Custom scripts instead return { success: true, url: canonicalResourceUri } after the service confirms the operation.

In a shared file, define the read-only MCP server and the custom job together:

---
mcp-servers:
notion:
container: "mcp/notion"
env:
NOTION_TOKEN: "${{ secrets.NOTION_TOKEN }}"
allowed:
- "search_pages"
- "get_page"
- "get_database"
- "query_database"
safe-outputs:
jobs:
notion-add-comment:
description: "Add a comment to a Notion page"
runs-on: ubuntu-latest
output: "Comment added to Notion successfully!"
permissions:
contents: read
inputs:
page_id:
description: "The Notion page ID to add a comment to"
required: true
type: string
comment:
description: "The comment text to add"
required: true
type: string
steps:
- name: Add comment to Notion page
uses: actions/github-script@v8
env:
NOTION_TOKEN: "${{ secrets.NOTION_TOKEN }}"
with:
script: |
const fs = require('fs');
const items = JSON.parse(fs.readFileSync(process.env.GH_AW_AGENT_OUTPUT, 'utf8'))
.items.filter(item => item.type === 'notion_add_comment');
for (const item of items) {
const response = await fetch('https://github.github.io/service/https://api.notion.com/v1/comments', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.NOTION_TOKEN}`,
'Notion-Version': '2022-06-28',
'Content-Type': 'application/json'
},
body: JSON.stringify({
parent: { page_id: item.page_id },
rich_text: [{ type: 'text', text: { content: item.comment } }]
})
});
if (!response.ok) {
core.setFailed(`Notion API error (${response.status}): ${await response.text()}`);
return;
}
}
---

Use container: for Docker servers or command:/args: for npx. List only read-only tools in allowed. Every job requires description and inputs; output sets the success message.

Import the configuration:

---
on:
issues:
types: [opened]
permissions:
contents: read
actions: read
imports:
- shared/mcp/notion.md
---
# Issue Summary to Notion
Analyze the issue: "${{ steps.sanitized.outputs.text }}"
Search for the GitHub Issues page in Notion using the read-only Notion tools, then add a summary comment using the notion-add-comment safe-job.

The agent queries with read-only tools, and the safe job runs with write permissions after the agent completes.

Custom jobs must add a generated-by footer linking to the workflow run and preserve workflow provenance annotations on published issues, discussions, and comments. Direct API calls bypass built-in handlers, so without attribution, audits cannot identify the items as workflow-generated. Custom jobs receive the same GH_AW_WORKFLOW_*, engine, tracker, message, and detection metadata as built-in jobs. For GitHub issues, use the createIssue helper below instead of hand-maintained footer templates.

actions/setup/js/index.cjs re-exports setupGlobals, createIssue, and logSpan. Run the setup action to install the runtime helpers, then load them from ${RUNNER_TEMP}/gh-aw/actions/index.cjs in actions/github-script:

safe-outputs:
jobs:
publish-finding:
description: "Publish one validated finding"
runs-on: ubuntu-latest
permissions:
issues: write
inputs:
body:
description: "Finding details"
required: true
type: string
steps:
- uses: github/gh-aw/actions/setup@main
- uses: actions/github-script@v9.0.0
with:
script: |
const fs = require("node:fs");
const actionsDir = `${process.env.RUNNER_TEMP}/gh-aw/actions`;
const { setupGlobals, createIssue } = require(`${actionsDir}/index.cjs`);
setupGlobals(core, github, context, exec, io, getOctokit);
const { sanitizeContent } = require(`${actionsDir}/sanitize_content.cjs`);
const output = JSON.parse(fs.readFileSync(process.env.GH_AW_AGENT_OUTPUT, "utf8"));
const items = output.items.filter(item => item.type === "publish_finding");
if (items.length !== 1 || typeof items[0].body !== "string") {
throw new Error("Exactly one finding with a string body is required");
}
const result = await createIssue({
title: "Validated workflow finding",
body: sanitizeContent(items[0].body),
});
if (!result.staged) core.info(result.issue.html_url);

Use a release that contains this API, and pin the setup action to a trusted commit in production.

APIContract
setupGlobals(core, github, context, exec, io, getOctokit)Initializes the runtime once before calling createIssue.
createIssue(parameters)Accepts Octokit issue-creation parameters with a required string body. owner and repo default to the workflow repository. Labels are sanitized and deduplicated. Returns { staged: false, issue }, or { staged: true, preview } when GH_AW_SAFE_OUTPUTS_STAGED is "true". Throws on invalid input or API failure.
logSpan(toolName, attributes?, options?)Re-exports the OpenTelemetry span helper. Export failures are non-fatal.

The helper adds the generated-by footer, run and history links, and workflow, caller, engine, and tracker annotations. A body over GitHub’s 65,536-character limit, including attribution, fails before creation; content is never silently truncated.

parameters.artifacts accepts up to 10 issue artifacts per issue, each with inline body up to 1 MB. The helper does not accept path, so the custom job must sanitize artifact names and content. Live results include artifacts: [{ number, url }]. If an attachment fails, the error names the already-created issue.

The helper does not perform grouping, deduplication, expiration, agent assignment, or manifest artifact upload. The custom job remains responsible for validating agent-controlled content, enforcing output counts and allowed repositories, and supplying a token with appropriate permissions.

Attaching a Text File as an Issue Artifact

Section titled “Attaching a Text File as an Issue Artifact”

For the built-in create_issue tool, use artifacts[].path. Trusted custom jobs using createIssue must pass body instead. Declare the agent’s file so it reaches the custom job:

artifacts:
- /tmp/gh-aw/agent/issue-plan.md

The compiler downloads declared files beside the agent output, preserving their paths relative to /tmp/gh-aw/. Read the file with the guarded-file helper and pass its sanitized text as body:

const path = require("node:path");
const { readGuardedFile } = require(`${actionsDir}/symlink_guard.cjs`);
const source = path.join(path.dirname(process.env.GH_AW_AGENT_OUTPUT), "agent", "issue-plan.md");
const body = sanitizeContent(readGuardedFile(source, 1048576).toString("utf8"), {
maxLength: Number.MAX_SAFE_INTEGER,
maxLines: Number.MAX_SAFE_INTEGER,
});
const result = await createIssue({
title: "Implement parser caching",
body: "The implementation plan is attached.",
artifacts: [{ type: "plan", name: "Implementation plan", body }],
});
if (!result.staged) core.info(result.issue.html_url);

Use a fixed declared path, not an agent-supplied one. Binary files need path-based file uploads instead.

PropertyTypeRequiredDescription
descriptionstringYesTool description shown to the agent
runs-onstringYesGitHub Actions runner (e.g., ubuntu-latest)
inputsobjectYesTool parameters (see Input Types)
stepsarrayYesGitHub Actions steps to execute
outputstringNoSuccess message returned to the agent
needsstring or arrayNoJobs that must complete first (see Job Ordering)
permissionsobjectNoGitHub token permissions for the job
envobjectNoEnvironment variables for all steps
ifstringNoConditional execution expression
timeout-minutesnumberNoMaximum job duration (GitHub Actions default: 360)
artifactsarray of stringsNoAgent-job filesystem paths this job depends on (see Depending on Agent-Job Files)

Frontmatter needs: sequences a custom job relative to other jobs. Unlike lock-file edits, it persists across recompiles:

safe-outputs:
create-issue: {}
jobs:
post-process:
needs: safe_outputs # runs after the consolidated safe-outputs job
steps:
- run: echo "post-processing"

The compiler validates each target, normalizes dashes to underscores, and rejects self-dependencies and cycles. Valid targets:

TargetAvailable when
agentAlways
safe_outputsAt least one builtin handler, script, action, or user step is configured
detectionThreat detection is enabled
upload_assetsupload-asset is configured
unlocklock-for-agent is enabled
<job-name>That job exists in safe-outputs.jobs

All jobs must define inputs:

TypeDescription
stringText input
booleanTrue/false (as strings: "true" or "false")
choiceSelection from predefined options

Inputs also support description, required, and default. String inputs can set max-length (1–1,000,000 characters) to replace the default 10,240-byte limit. Use it for large structured values that must stay in the safe-output item. For files or directories, use artifacts: instead.

inputs:
message:
description: "Message content"
required: true
type: string
max-length: 40000
notify:
description: "Send notification"
required: false
type: boolean
default: "true"
environment:
description: "Target environment"
required: true
type: choice
options: ["staging", "production"]
VariableDescription
GH_AW_AGENT_OUTPUTPath to JSON file containing the agent’s output data
GH_AW_SAFE_OUTPUTS_STAGEDSet to "true" when running in staged/preview mode

GH_AW_AGENT_OUTPUT points to a JSON file shaped like this:

{
"items": [
{ "type": "job_name_with_underscores", "field1": "value1" }
]
}

The type is the job name with dashes converted to underscores (webhook-notify becomes webhook_notify). The inputs: schema is both the tool definition the agent sees and the validation for these fields.

The agent job uploads only a fixed set of compiler-managed paths. Files the agent writes elsewhere, such as under /tmp/gh-aw/agent/, are silently dropped unless you declare them with artifacts::

safe-outputs:
jobs:
publish-review-bundle:
description: "Publish a prepared directory as a workflow artifact"
runs-on: ubuntu-latest
artifacts:
- /tmp/gh-aw/agent/review-bundles/
inputs:
source_dir:
description: "Directory under /tmp/gh-aw/agent/ containing prepared files"
required: true
type: string
steps:
- run: echo "processing bundle"

Entries must be literal paths or globs under /tmp/gh-aw/. Expressions and control characters are rejected, and so are .. segments, hidden files, and paths outside that root. Directory paths end with / and expand to recursive globs. Only files with these extensions are persisted: .txt, .json, .log, .md, .mdx, .yml, .jsonl, or .patch. Symbolic links are removed before upload.

Prefer embedding small values in the safe-output item, where later stages can inspect them. Use artifacts: for directories or multiple files.

If secret redaction fails, the source is removed and the step fails. Diagnostics report only a fixed stage (read, redact, write, replace, or unlink) and an allowlisted error code such as EACCES or ENOSPC, never messages, paths, or secret values. A failed removal is still fatal, so unsanitized content is never uploaded.

Inline Script Handlers (safe-outputs.scripts)

Section titled “Inline Script Handlers (safe-outputs.scripts)”

Use safe-outputs.scripts for lightweight inline JavaScript. It runs in-process inside the consolidated safe-outputs job, once per tool call, with no job startup overhead. Scripts cannot access repository secrets.

Choosing Between Scripts, Actions, and Jobs

Section titled “Choosing Between Scripts, Actions, and Jobs”
ScriptsActionsJobs
ExecutionIn-process, in the consolidated safe-outputs jobStep in the consolidated safe-outputs jobSeparate GitHub Actions job
InvocationOnce per tool call (each call gets its own item)Once per workflow runOnce per workflow run (loops over .items[])
SecretsNot directly availableFull access via env:Full access
ReuseCustom inline JavaScriptAny public GitHub ActionCustom YAML job
Typical useLightweight processing, logging, or notificationsReusing marketplace actionsExternal API calls that need secrets

Under safe-outputs.scripts, define each handler with a description, inputs, and a script body:

.github/workflows/my-workflow.md
---
safe-outputs:
scripts:
post-slack-message:
description: Post a message to a Slack channel
inputs:
channel:
description: Slack channel name
required: true
type: string
message:
description: Message text
required: true
type: string
script: |
const targetChannel = item.channel || "#general";
const text = item.message || "(no message)";
core.info(`Posting to ${targetChannel}: ${text}`);
return { success: true, channel: targetChannel };
---

Write only the handler body; the compiler wraps it. The body can use:

VariableDescription
itemRuntime message object with field values matching your inputs schema
core@actions/core for logging (core.info(), core.warning(), core.error())
resolvedTemporaryIdsMap of temporary object IDs resolved at runtime

Each declared input is also available as a local variable (inputs.channel is channel as well as item.channel).

PropertyTypeRequiredDescription
descriptionstringYesTool description shown to the agent
inputsobjectYesTool parameters (same schema as custom jobs)
scriptstringYesJavaScript handler body

Scripts support the string, boolean, and number input types.

GitHub Action Wrappers (safe-outputs.actions)

Section titled “GitHub Action Wrappers (safe-outputs.actions)”

Use safe-outputs.actions to expose a public GitHub Action as a tool the agent can call once per workflow run. At compile time, gh aw compile fetches the action’s action.yml to resolve its inputs and pins the reference to a SHA.

.github/workflows/my-workflow.md
---
safe-outputs:
actions:
add-smoked-label:
uses: actions-ecosystem/action-add-labels@v1
description: Add the 'smoked' label to the current pull request
env:
GITHUB_TOKEN: ${{ github.token }}
---

The agent calls add_smoked_label. The action’s declared inputs become the tool’s parameters.

PropertyTypeRequiredDescription
usesstringYesAction reference (owner/repo@ref or ./path/to/local-action)
descriptionstringNoOverrides the action’s own description
envobjectNoAdditional environment variables for the action step

Define jobs in shared files under .github/workflows/shared/ and import them:

---
on: issues
permissions:
contents: read
imports:
- shared/slack-notify.md
- shared/jira-integration.md
---
# Issue Handler
Handle the issue and notify via Slack and Jira.

Jobs with duplicate names cause compilation errors. Rename one to resolve the conflict.

Store secrets in GitHub Secrets and pass them through environment variables. Limit job permissions to the minimum required, and validate all inputs.

When GH_AW_SAFE_OUTPUTS_STAGED === 'true', skip the real operation and show a preview with core.summary. See Staged Mode for an example.

IssueSolution
Job or script not appearing as toolEnsure inputs and description are defined; verify import path; run gh aw compile
Secrets not availableCheck the secret exists and the name matches exactly (case-sensitive)
Job fails silentlyAdd core.info() logging and call core.setFailed() on errors
Agent calls wrong toolMake description specific and unique; name the job in the prompt