Skip to content

Deploy a preview when an agent finishes work

Business plan Owner or Admin

An agent finishes a task and opens a pull request. Your CI builds a preview environment for it. Everyone on the task can then open that preview from Onplana, and anything else you run (smoke tests, a review request, a Slack notice) can fire the moment it is ready.

This is a loop with two halves, and they use different mechanisms:

DirectionWhat movesHow
In to Onplanayour CI puts the preview URL on the taskthe update_task tool over MCP
Out of Onplanayour pipeline reacts to thatthe task.preview_ready webhook

You can build either half on its own. The inbound half alone already gets you a clickable preview link on the task, which is most of the value.

  • A pull request URL and a preview URL produced by your own pipeline.
  • The Onplana task id the work belongs to. If an agent opened the PR, it already knows this; see passing the task id if not.
  • An API token with agent access, created under Settings, then Developer. Store it in your CI’s secret store, never in the workflow file.

The call is a single JSON-RPC POST:

Terminal window
curl -sS https://mcp.onplana.com/mcp \
-H "Authorization: Bearer $ONPLANA_TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "update_task",
"arguments": {
"taskId": "'"$ONPLANA_TASK_ID"'",
"previewUrl": "'"$PREVIEW_URL"'",
"prUrl": "'"$PR_URL"'"
}
}
}'

Both Accept values are required; the endpoint refuses the request without them.

name: preview
on: pull_request
jobs:
deploy-preview:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Your own build and deploy. Whatever this produces, expose the
# resulting URL as an output so the next step can post it back.
- id: deploy
run: |
# ...build and deploy...
echo "url=https://pr-${{ github.event.number }}.preview.example.com" >> "$GITHUB_OUTPUT"
# Find the Onplana task id in the PR body: "Onplana-Task: <id>".
- id: task
run: |
id=$(printf '%s' "${{ github.event.pull_request.body }}" \
| grep -oiE 'Onplana-Task:[[:space:]]*[a-z0-9]+' \
| head -1 | tr -d ' ' | cut -d: -f2)
echo "id=$id" >> "$GITHUB_OUTPUT"
- name: Post the preview back to Onplana
if: steps.task.outputs.id != ''
env:
ONPLANA_TOKEN: ${{ secrets.ONPLANA_TOKEN }}
run: |
curl -sS --fail-with-body https://mcp.onplana.com/mcp \
-H "Authorization: Bearer $ONPLANA_TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d "$(jq -n \
--arg t "${{ steps.task.outputs.id }}" \
--arg p "${{ steps.deploy.outputs.url }}" \
--arg r "${{ github.event.pull_request.html_url }}" \
'{jsonrpc:"2.0",id:1,method:"tools/call",
params:{name:"update_task",
arguments:{taskId:$t,previewUrl:$p,prUrl:$r}}}')"

The if: guard matters. A pull request opened by a human with no task reference should skip this step, not fail the build.

trigger: none
pr:
branches:
include: [ main ]
steps:
- checkout: self
- script: |
# ...build and deploy...
echo "##vso[task.setvariable variable=previewUrl]https://pr-$(System.PullRequest.PullRequestId).preview.example.com"
displayName: Deploy preview
# Azure Pipelines does not expose the pull request DESCRIPTION as a
# predefined variable, so this reads the task id from the source BRANCH
# instead: onplana/<task-id>/short-description.
- script: |
id=$(printf '%s' "$(System.PullRequest.SourceBranch)" \
| sed -n 's#^refs/heads/onplana/\([a-z0-9]\{1,\}\)/.*#\1#p')
echo "##vso[task.setvariable variable=onplanaTaskId]$id"
displayName: Find the Onplana task id
- script: |
test -n "$(onplanaTaskId)" || exit 0
curl -sS --fail-with-body https://mcp.onplana.com/mcp \
-H "Authorization: Bearer $(ONPLANA_TOKEN)" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",
\"params\":{\"name\":\"update_task\",
\"arguments\":{\"taskId\":\"$(onplanaTaskId)\",
\"previewUrl\":\"$(previewUrl)\",
\"prUrl\":\"$(System.PullRequest.SourceRepositoryURI)/pullrequest/$(System.PullRequest.PullRequestId)\"}}}"
displayName: Post the preview back to Onplana

Add ONPLANA_TOKEN as a secret pipeline variable, not a plain one, so it is masked in logs.

CI needs to know which task a pull request belongs to, and which signal is available depends on your platform, which is why the two examples above differ:

The PR body, a line like Onplana-Task: <id>. Visible to human reviewers, survives rebases and retitling. GitHub Actions exposes it directly as github.event.pull_request.body. Azure Pipelines does not expose the pull request description as a predefined variable at all, so reading it there means calling the Azure DevOps REST API, which is more moving parts than a recipe should need.

The branch name, onplana/<task-id>/short-description. Available on every platform as an ordinary variable, and easy to read at a glance. The cost is that branches get renamed, so it is a weaker source of truth.

If you use both platforms, standardise on the branch convention and have agents put the line in the PR body as well. Then either signal works and the two pipelines can be near-identical.

Tell the agent which convention you use in its instructions, or in the task’s agent brief. It has the task id already; it just needs to know where to put it.

Once the URL lands on the task, Onplana fires the task.preview_ready webhook. Subscribe to it under Settings, then Developer, then Webhooks; see Send webhooks to external services for creating an endpoint and verifying signatures.

The payload:

{
"event": "task.preview_ready",
"timestamp": "2026-08-17T12:00:00.000Z",
"data": {
"taskId": "cm...",
"projectId": "cm...",
"title": "Add the export button",
"previewUrl": "https://pr-482.preview.example.com",
"prUrl": "https://github.com/acme/app/pull/482"
}
}

Typical things to hang off it: run smoke tests against the preview and flip the PR to ready for review only if they pass; post the link into the channel where the work is being discussed; or start a visual-diff run.

Verify the X-Webhook-Signature header on every delivery. It is the HMAC-SHA256 of the raw request body, and an empty signature means unsigned: reject it.

The preview URL never appears on the task Almost always the REST-versus-MCP mistake. Check you are posting to https://mcp.onplana.com/mcp as a JSON-RPC tools/call, not to the REST task endpoint, and that the token has agent access rather than only task read/write.

The call returns a “not acceptable” error The Accept header must contain both application/json and text/event-stream. The endpoint refuses the request otherwise.

The webhook never fires but the URL is on the task The event only fires on the edge. If the URL was already set to that exact value the update is a no-op for this purpose. Clear it ("previewUrl": null) and set it again to test.

The step fails on pull requests from humans Guard on an empty task id, as both examples above do. A PR with no Onplana task reference should skip the step rather than fail the build.

Deliveries are failing Check the delivery history on the webhook itself, under Settings, then Developer, then Webhooks. It records the response status of each attempt.