Deploy a preview when an agent finishes work
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:
| Direction | What moves | How |
|---|---|---|
| In to Onplana | your CI puts the preview URL on the task | the update_task tool over MCP |
| Out of Onplana | your pipeline reacts to that | the 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.
Before you start
Section titled “Before you start”- 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.
Put the preview URL on the task
Section titled “Put the preview URL on the task”The call is a single JSON-RPC POST:
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.
GitHub Actions
Section titled “GitHub Actions”name: previewon: 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.
Azure Pipelines
Section titled “Azure Pipelines”trigger: nonepr: 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 OnplanaAdd ONPLANA_TOKEN as a secret pipeline variable, not a plain one, so it is
masked in logs.
Passing the task id
Section titled “Passing the task id”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.
React when a preview is ready
Section titled “React when a preview is ready”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.
Troubleshooting
Section titled “Troubleshooting”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.
Related
Section titled “Related”- Send webhooks to external services, creating the endpoint and verifying signatures
- Connect an external agent, the agent that opens the pull request
- Run an agent on your own API key, letting Onplana run that agent for you
Was this helpful?
Thanks for your feedback!