Create an actions ROI dashboard
When trying to justify investing in self-service actions, leadership expects to see rigorous, evidence-based metrics.
This guide demonstrates how to build a Port dashboard that quantifies ROI, highlights time savings and efficiency gains, and clearly communicates business value.
 
We will be using the following terms throughout the guide:
- 
Lead time saving measures the amount of time saved by using self-service actions through Port, compared to manual request processes. Lead time covers the entire request journey, from when a request is submitted until itβs completed. 
- 
Lead time before: How long requests typically took from submission to completion before using actions. 
- 
Cycle Time Saving measures how much faster the execution phase is once a request has been approved or started. Unlike lead time, cycle time looks only at delivery speed, not the waiting time before work begins. 
- 
Cycle time before: How long the actual execution step typically took before using actions. 
Common Use Casesβ
- Demonstrate to leadership the business value of actions by sharing clear success metrics.
- Track improvements in cycle time and lead time to see which actions deliver the most impact.
Prerequisitesβ
Set up data modelβ
Create the Action categories blueprintβ
- 
Go to your Builder page. 
- 
Click on + Blueprint.
- 
Click on the {...} Edit JSONbutton in the top right corner.
- 
Copy and paste the following JSON configuration into the editor: Action categories blueprint (click to expand){
 "identifier": "actions_categories",
 "description": "Categories for actions",
 "title": "Actions categories",
 "icon": "Microservice",
 "schema": {
 "properties": {},
 "required": []
 },
 "mirrorProperties": {},
 "calculationProperties": {},
 "aggregationProperties": {},
 "relations": {}
 }
- 
Click Create.
Create the Action run blueprintβ
- 
Go to your Builder page. 
- 
Click on + Blueprint.
- 
Click on the {...} Edit JSONbutton in the top right corner.
- 
Copy and paste the following JSON configuration into the editor: Action Run blueprint (click to expand){
 "identifier": "action_run",
 "title": "Action run",
 "icon": "Microservice",
 "schema": {
 "properties": {
 "status": {
 "icon": "DefaultProperty",
 "title": "Status",
 "type": "string",
 "enum": [
 "SUCCESS",
 "FAILURE",
 "IN_PROGRESS",
 "WAITING_FOR_APPROVAL",
 "DECLINED"
 ],
 "enumColors": {
 "SUCCESS": "green",
 "FAILURE": "red",
 "IN_PROGRESS": "lightGray",
 "WAITING_FOR_APPROVAL": "yellow",
 "DECLINED": "red"
 }
 },
 "created_at": {
 "type": "string",
 "title": "Created At",
 "format": "date-time"
 },
 "run_id": {
 "type": "string",
 "title": "Run ID"
 },
 "run_url": {
 "type": "string",
 "title": "Run URL",
 "format": "url"
 },
 "updated_at": {
 "type": "string",
 "title": "Updated At",
 "format": "date-time"
 },
 "duration": {
 "type": "number",
 "title": "Duration",
 "description": "In seconds"
 },
 "waiting_for_approval_duration": {
 "type": "number",
 "title": "Waiting for approval duration",
 "description": "In seconds"
 },
 "cycle_time": {
 "icon": "DefaultProperty",
 "title": "Cycle Time",
 "description": "Total time from in progress to completion in seconds",
 "type": "number"
 }
 },
 "required": []
 },
 "mirrorProperties": {
 "ran_by": {
 "title": "Ran by",
 "path": "ran_by_actual_user.$identifier"
 },
 "team_2": {
 "title": "User teams",
 "path": "ran_by_actual_user.$team.$identifier"
 },
 "user_group": {
 "title": "User group",
 "path": "ran_by_actual_user.$team.group.$title"
 }
 },
 "calculationProperties": {
 "savings_lead_time_h": {
 "title": "Savings lead time (h)",
 "icon": "DefaultProperty",
 "description": "Time saved on waiting time In hours",
 "calculation": "if .properties.status == \"SUCCESS\" then (.properties.lead_time_before * 3600 - .properties.duration) / 3600 else null end",
 "type": "number"
 },
 "savings_cycle_time_h": {
 "title": "Savings cycle time (h)",
 "icon": "DefaultProperty",
 "description": "Time saved on cycle time In hours",
 "calculation": "if .properties.status == \"SUCCESS\" then (.properties.cycle_time_before*3600 - .properties.cycle_time) / 3600 else null end",
 "type": "number"
 },
 "primary_user_group": {
 "title": "Primary User Group",
 "icon": "GroupBy",
 "description": "First user group from the user_group array",
 "calculation": "if (.properties.user_group | type) == \"array\" and (.properties.user_group | length > 0) then .properties.user_group[0] else (.properties.user_group // \"No Group\") end",
 "type": "string"
 },
 "primary_team": {
 "title": "Primary Team",
 "icon": "Team",
 "description": "First team from the team_2 array",
 "calculation": "if (.properties.team_2 | type) == \"array\" and (.properties.team_2 | length > 0) then .properties.team_2[0] else (.properties.team_2 // \"No Team\") end",
 "type": "string"
 }
 },
 "aggregationProperties": {},
 "relations": {
 "ran_by_actual_user": {
 "title": "Ran by actual user",
 "target": "_user",
 "required": false,
 "many": false
 }
 }
 }
- 
Click Create.
Create the Action blueprintβ
- 
Go to your Builder page. 
- 
Click on + Blueprint.
- 
Click on the {...} Edit JSONbutton in the top right corner.
- 
Copy and paste the following JSON configuration into the editor: Action blueprint (click to expand){
 "identifier": "action",
 "description": "Action as a blueprint",
 "title": "Action",
 "icon": "Microservice",
 "ownership": {
 "type": "Direct",
 "title": "Owning Teams"
 },
 "schema": {
 "properties": {
 "lead_time_before": {
 "icon": "DefaultProperty",
 "type": "number",
 "title": "Lead time before(h)"
 },
 "cycle_time": {
 "icon": "DefaultProperty",
 "type": "number",
 "title": "Cycle time before(h)"
 },
 "description": {
 "type": "string",
 "title": "Description"
 },
 "criticality": {
 "icon": "DefaultProperty",
 "title": "Criticality",
 "type": "string",
 "default": "Tier 3",
 "enum": [
 "Tier 1",
 "Tier 2",
 "Tier 3"
 ],
 "enumColors": {
 "Tier 1": "red",
 "Tier 2": "orange",
 "Tier 3": "lightGray"
 }
 }
 },
 "required": [
 "criticality"
 ]
 },
 "mirrorProperties": {},
 "calculationProperties": {
 "overall_failure_rate": {
 "title": "Overall Failure Rate (%)",
 "icon": "DefaultProperty",
 "description": "Percentage of failed runs out of total runs",
 "calculation": "if .properties.total_runs > 0 then (.properties.failed_runs / .properties.total_runs * 100) else 0 end",
 "type": "number"
 },
 "success_rate": {
 "title": "Success Rate (%)",
 "description": "Percentage of successful runs out of total runs",
 "calculation": "if .properties.total_runs > 0 then (.properties.successful_runs / .properties.total_runs * 100) else 0 end",
 "type": "number"
 },
 "daily_failure_rate": {
 "title": "Daily Failure Rate (%)",
 "description": "Percentage of failed runs in the last day",
 "calculation": "if .properties.daily_total_runs > 0 then (.properties.daily_failed_runs / .properties.daily_total_runs * 100) else 0 end",
 "type": "number"
 },
 "weekly_failure_rate": {
 "title": "Weekly Failure Rate (%)",
 "description": "Percentage of failed runs in the last week",
 "calculation": "if .properties.weekly_total_runs > 0 then (.properties.weekly_failed_runs / .properties.weekly_total_runs * 100) else 0 end",
 "type": "number"
 },
 "monthly_failure_rate": {
 "title": "Monthly Failure Rate (%)",
 "description": "Percentage of failed runs in the last month",
 "calculation": "if .properties.monthly_total_runs > 0 then (.properties.monthly_failed_runs / .properties.monthly_total_runs * 100) else 0 end",
 "type": "number"
 },
 "total_runs_with_status": {
 "title": "Total Runs (with status)",
 "description": "Sum of successful and failed runs",
 "calculation": "(.properties.successful_runs // 0) + (.properties.failed_runs // 0)",
 "type": "number"
 },
 "total_waiting_time_h": {
 "title": "Total Waiting Time (h)",
 "icon": "HourGlass",
 "description": "Total waiting for approval time across all runs in hours",
 "calculation": "if .properties.total_waiting_time_seconds > 0 then .properties.total_waiting_time_seconds / 3600 else 0 end",
 "type": "number"
 },
 "average_waiting_time_h": {
 "title": "Average Waiting Time (h)",
 "icon": "HourGlass",
 "description": "Average waiting for approval duration per run in hours",
 "calculation": "if .properties.runs_with_waiting_time > 0 then (.properties.total_waiting_time_seconds / .properties.runs_with_waiting_time) / 3600 else 0 end",
 "type": "number"
 },
 "max_waiting_time_h": {
 "title": "Max Waiting Time (h)",
 "icon": "HourGlassExpired",
 "description": "Maximum waiting time for any single run in hours",
 "calculation": "if .properties.max_waiting_time_seconds > 0 then .properties.max_waiting_time_seconds / 3600 else 0 end",
 "type": "number"
 }
 },
 "aggregationProperties": {
 "total_lead_time_savings_h": {
 "title": "Total lead time savings (h)",
 "type": "number",
 "description": "In hours",
 "target": "action_run",
 "calculationSpec": {
 "func": "sum",
 "property": "savings_lead_time_h",
 "calculationBy": "property"
 }
 },
 "total_runs": {
 "title": "Total Runs",
 "type": "number",
 "description": "Total number of action runs",
 "target": "action_run",
 "calculationSpec": {
 "func": "count",
 "calculationBy": "entities"
 }
 },
 "failed_runs": {
 "title": "Failed Runs",
 "icon": "DefaultProperty",
 "type": "number",
 "description": "Number of failed action runs",
 "target": "action_run",
 "query": {
 "combinator": "and",
 "rules": [
 {
 "property": "status",
 "operator": "=",
 "value": "FAILURE"
 }
 ]
 },
 "calculationSpec": {
 "func": "count",
 "calculationBy": "entities"
 }
 },
 "successful_runs": {
 "title": "Successful Runs",
 "type": "number",
 "description": "Number of successful action runs",
 "target": "action_run",
 "query": {
 "combinator": "and",
 "rules": [
 {
 "property": "status",
 "operator": "=",
 "value": "SUCCESS"
 }
 ]
 },
 "calculationSpec": {
 "func": "count",
 "calculationBy": "entities"
 }
 },
 "in_progress_runs": {
 "title": "In Progress Runs",
 "type": "number",
 "description": "Number of currently running actions",
 "target": "action_run",
 "query": {
 "combinator": "and",
 "rules": [
 {
 "property": "status",
 "operator": "=",
 "value": "IN_PROGRESS"
 }
 ]
 },
 "calculationSpec": {
 "func": "count",
 "calculationBy": "entities"
 }
 },
 "daily_total_runs": {
 "title": "Daily Total Runs",
 "icon": "DefaultProperty",
 "type": "number",
 "description": "Total runs in the last 24 hours",
 "target": "action_run",
 "query": {
 "combinator": "and",
 "rules": [
 {
 "property": "$createdAt",
 "operator": "between",
 "value": {
 "preset": "today"
 }
 }
 ]
 },
 "calculationSpec": {
 "func": "count",
 "calculationBy": "entities"
 }
 },
 "daily_failed_runs": {
 "title": "Daily Failed Runs",
 "icon": "DefaultProperty",
 "type": "number",
 "description": "Failed runs in the last 24 hours",
 "target": "action_run",
 "query": {
 "combinator": "and",
 "rules": [
 {
 "property": "status",
 "operator": "=",
 "value": "FAILURE"
 },
 {
 "property": "$createdAt",
 "operator": "between",
 "value": {
 "preset": "today"
 }
 }
 ]
 },
 "calculationSpec": {
 "func": "count",
 "calculationBy": "entities"
 }
 },
 "weekly_total_runs": {
 "title": "Weekly Total Runs",
 "icon": "DefaultProperty",
 "type": "number",
 "description": "Total runs in the last 7 days",
 "target": "action_run",
 "query": {
 "combinator": "and",
 "rules": [
 {
 "property": "$createdAt",
 "operator": "between",
 "value": {
 "preset": "lastWeek"
 }
 }
 ]
 },
 "calculationSpec": {
 "func": "count",
 "calculationBy": "entities"
 }
 },
 "weekly_failed_runs": {
 "title": "Weekly Failed Runs",
 "icon": "DefaultProperty",
 "type": "number",
 "description": "Failed runs in the last week",
 "target": "action_run",
 "query": {
 "combinator": "and",
 "rules": [
 {
 "property": "status",
 "operator": "=",
 "value": "FAILURE"
 },
 {
 "property": "$createdAt",
 "operator": "between",
 "value": {
 "preset": "lastWeek"
 }
 }
 ]
 },
 "calculationSpec": {
 "func": "count",
 "calculationBy": "entities"
 }
 },
 "monthly_total_runs": {
 "title": "Monthly Total Runs",
 "icon": "DefaultProperty",
 "type": "number",
 "description": "Total runs in the last 30 days",
 "target": "action_run",
 "query": {
 "combinator": "and",
 "rules": [
 {
 "property": "$createdAt",
 "operator": "between",
 "value": {
 "preset": "lastMonth"
 }
 }
 ]
 },
 "calculationSpec": {
 "func": "count",
 "calculationBy": "entities"
 }
 },
 "monthly_failed_runs": {
 "title": "Monthly Failed Runs",
 "icon": "DefaultProperty",
 "type": "number",
 "description": "Failed runs in the last month",
 "target": "action_run",
 "query": {
 "combinator": "and",
 "rules": [
 {
 "property": "status",
 "operator": "=",
 "value": "FAILURE"
 },
 {
 "property": "$createdAt",
 "operator": "between",
 "value": {
 "preset": "lastMonth"
 }
 }
 ]
 },
 "calculationSpec": {
 "func": "count",
 "calculationBy": "entities"
 }
 },
 "total_cycle_time_savings_h": {
 "title": "Total cycle time savings (h)",
 "icon": "DefaultProperty",
 "type": "number",
 "description": "In hours",
 "target": "action_run",
 "calculationSpec": {
 "func": "sum",
 "property": "savings_cycle_time_h",
 "calculationBy": "property"
 }
 },
 "total_waiting_time_seconds": {
 "title": "Total Waiting Time (seconds)",
 "icon": "HourGlass",
 "type": "number",
 "description": "Total waiting for approval time across all runs in seconds",
 "target": "action_run",
 "calculationSpec": {
 "func": "sum",
 "property": "waiting_for_approval_duration",
 "calculationBy": "property"
 }
 },
 "max_waiting_time_seconds": {
 "title": "Max approving Time (s)",
 "icon": "HourGlassExpired",
 "type": "number",
 "description": "Maximum waiting for approval in seconds",
 "target": "action_run",
 "calculationSpec": {
 "func": "max",
 "property": "waiting_for_approval_duration",
 "calculationBy": "property"
 }
 },
 "runs_with_waiting_time": {
 "title": "Runs with Waiting Time",
 "icon": "HourGlass",
 "type": "number",
 "description": "Number of runs that had waiting time",
 "target": "action_run",
 "query": {
 "combinator": "and",
 "rules": [
 {
 "property": "waiting_for_approval_duration",
 "operator": ">",
 "value": 0
 }
 ]
 },
 "calculationSpec": {
 "func": "count",
 "calculationBy": "entities"
 }
 }
 },
 "relations": {
 "category": {
 "title": "Category",
 "target": "actions_categories",
 "required": true,
 "many": false
 }
 }
 }
- 
Click Create.
Connect the blueprintsβ
After setting up both Action Run and Action blueprint, add the following relation and mirror properties to the Action blueprint.
Action run blueprint (click to expand)
    "mirrorProperties": {
        "category": {
        "title": "Category",
        "path": "parent_action.category.$title"
        },
        "lead_time_before": {
        "title": "Lead time before",
        "path": "parent_action.lead_time_before"
        },
        "cycle_time_before": {
        "title": "Cycle time before",
        "path": "parent_action.cycle_time"
        },
        "action": {
        "title": "Action",
        "path": "parent_action.$title"
        }
    },
    "relations": {
        "parent_action": {
        "title": "Parent action",
        "target": "action",
        "required": false,
        "many": false
        }
    }
Create the self-service actionβ
This self-service action performs the following:
- 
If a self-service action with the provided title doesnβt exist, the workflow creates a minimal self-service action, otherwise it uses the existing one. 
- 
It then creates an automation that triggers on any run change for that action. 
 On trigger, if the run succeeded, the automation updates the followingAction runproperties:- Duration: How long did the action run take.
- Waiting for approval duration: How long did the request take to get approved.
- Cycle time: The execution time after approval.
 All of which are used for aggregation properties in the Actionblueprint.
Set up the action's frontendβ
- 
Head to the Self-service page. 
- 
Click on the + Actionbutton.
- 
Click on the {...} Edit JSONbutton.
- 
Copy and paste the following JSON configuration into the editor: Action blueprint (click to expand)Remember to change the <YOUR-ORG-NAME>and<YOUR-REPO-NAME>to your GitHub organization and repository names.{
 "identifier": "setup_new_action",
 "title": "Setup new action experience",
 "description": "Setup a backend that will create scaffolding of action, automation and action kpis",
 "trigger": {
 "type": "self-service",
 "operation": "CREATE",
 "userInputs": {
 "properties": {
 "category": {
 "type": "string",
 "title": "Category",
 "blueprint": "actions_categories",
 "format": "entity"
 },
 "lead_time_before": {
 "type": "number",
 "description": "Number of hours spent waiting from ticket creation",
 "title": "Lead time before"
 },
 "cycle_time": {
 "icon": "DefaultProperty",
 "type": "number",
 "title": "Cycle time before",
 "description": "Time spent in hour executing the request"
 },
 "actionTitle": {
 "icon": "DefaultProperty",
 "type": "string",
 "title": "Action title"
 }
 },
 "required": [
 "category",
 "actionTitle"
 ],
 "order": [
 "4af618d8-6b42-45c1-81f8-34ead11eb3f5",
 "actionTitle",
 "category",
 "41281872-3eaa-4e6e-b66e-1f3e9bc7d99b",
 "lead_time_before",
 "cycle_time"
 ],
 "titles": {
 "4af618d8-6b42-45c1-81f8-34ead11eb3f5": {
 "title": "Set up a new action",
 "description": "This will create a new action and the associated action runs as blueprints"
 },
 "41281872-3eaa-4e6e-b66e-1f3e9bc7d99b": {
 "title": "ROI",
 "description": "Leave blank if no data or n/a"
 }
 }
 },
 "actionCardButtonText": "Create",
 "executeActionButtonText": "Create",
 "blueprintIdentifier": "action"
 },
 "invocationMethod": {
 "type": "GITHUB",
 "org": "<YOUR-ORG-NAME>",
 "repo": "<YOUR-REPO-NAME>",
 "workflow": "create-port-automation.yml",
 "workflowInputs": {
 "{{ spreadValue() }}": "{{ .inputs }}",
 "port_context": {
 "runId": "{{ .run.id }}",
 "blueprint": "{{ .action.blueprint }}"
 }
 },
 "reportWorkflowStatus": true
 },
 "requiredApproval": false
 }
- 
Click Save.
Set up the action's backendβ
Define the logic that our action will trigger.
In the repository where your workflow will reside, create two new secrets under Settings -> Secrets and variables -> Actions:
- PORT_CLIENT_ID- the client ID you copied from your Port app.
- PORT_CLIENT_SECRET- the client secret you copied from your Port app.
Add the workflow to the .github/workflows/ folder, and the other scripts to a ./scripts folder.
Create Port automation workflow (click to expand)
To the .github/workflows directory, add the following file:
name: Create Port Automation
on:
workflow_dispatch:
    inputs:
    actionTitle:
        description: "Action Title (e.g. Create S3 Bucket)"
        required: true
    category:
        description: "Category object (passed as raw JSON string)"
        required: true
    lead_time_before:
        description: "Optional: lead time before (number)"
        required: false
    cycle_time:
        description: "Optional: cycle time (number)"
        required: false
    port_context:
        description: "Includes blueprint, run ID, and entity identifier from Port"
        required: true
jobs:
create-automation:
    runs-on: ubuntu-latest
    outputs:
    action_identifier: ${{ steps.generate.outputs.action_identifier }}
    steps:
    - name: Checkout code
        uses: actions/checkout@v3
    - name: Install jq
        run: sudo apt-get install -y jq
    - name: Derive actionIdentifier from actionTitle
        id: generate
        run: |
        RAW_TITLE="${{ github.event.inputs.actionTitle }}"
        ACTION_IDENTIFIER=$(echo "$RAW_TITLE" | tr '[:upper:]' '[:lower:]' | sed 's/ /_/g')
        echo "π€ Derived identifier: $ACTION_IDENTIFIER"
        echo "action_identifier=$ACTION_IDENTIFIER" >> "$GITHUB_OUTPUT"
    - name: Extract Port run ID
        id: context
        run: |
        echo "run_id=${{ fromJson(github.event.inputs.port_context).runId }}" >> "$GITHUB_OUTPUT"
    - name: Create Port action if it doesn't exist
        env:
        PORT_CLIENT_ID: ${{ secrets.PORT_CLIENT_ID }}
        PORT_CLIENT_SECRET: ${{ secrets.PORT_CLIENT_SECRET }}
        run: |
        bash ./scripts/create_port_action_if_missing.sh \
            "${{ steps.generate.outputs.action_identifier }}" \
            "${{ github.event.inputs.actionTitle }}" \
            "${{ steps.context.outputs.run_id }}"
    - name: Create automation in Port
        env:
        PORT_CLIENT_ID: ${{ secrets.PORT_CLIENT_ID }}
        PORT_CLIENT_SECRET: ${{ secrets.PORT_CLIENT_SECRET }}
        run: |
        bash ./scripts/create_port_automation.sh \
            "${{ steps.generate.outputs.action_identifier }}" \
            "${{ steps.context.outputs.run_id }}"
    - name: Create entity in blueprint 'action'
        env:
        PORT_CLIENT_ID: ${{ secrets.PORT_CLIENT_ID }}
        PORT_CLIENT_SECRET: ${{ secrets.PORT_CLIENT_SECRET }}
        run: |
        bash ./scripts/create_action_entity.sh \
            "${{ steps.generate.outputs.action_identifier }}" \
            "${{ fromJson(github.event.inputs.category).identifier }}" \
            "${{ github.event.inputs.lead_time_before || '0' }}" \
            "${{ github.event.inputs.cycle_time || '0'  }}" \
            "${{ steps.context.outputs.run_id }}" \
            "${{ github.event.inputs.actionTitle }}"
Create action entity (click to expand)
#!/bin/bash
set -euo pipefail
# ===[ Configuration ]===
# Uncomment the correct region:
# PORT_API_BASE_URL="https://api.us.port.io"
PORT_API_BASE_URL="https://api.getport.io"
CACHE_FILE=".cache/port_token"
CACHE_TTL_SECONDS=3300
# ===[ Accept Parameters ]===
ACTION_IDENTIFIER="$1"
CATEGORY_IDENTIFIER="$2"
LEAD_TIME_BEFORE="${3:-}"
CYCLE_TIME="${4:-}"
PORT_RUN_ID="$5"
ACTION_TITLE="$6"
if [[ -z "$ACTION_IDENTIFIER" || -z "$CATEGORY_IDENTIFIER" || -z "$PORT_RUN_ID" || -z "$ACTION_TITLE" ]]; then
echo "β Usage: $0 <actionIdentifier> <categoryIdentifier> [leadTimeBefore] [cycleTime] <runId> <actionTitle>"
exit 1
fi
# ===[ Logging Function ]===
post_log() {
local MSG="$1"
echo "$MSG"
if [[ -n "${TOKEN:-}" && -n "$PORT_RUN_ID" ]]; then
    RESPONSE=$(curl -s -w "%{http_code}" -o .port_log_response \
    -X POST "$PORT_API_BASE_URL/v1/actions/runs/$PORT_RUN_ID/logs" \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    -d "{\"message\": \"$MSG\"}")
    STATUS="$RESPONSE"
    if [[ "$STATUS" != "201" ]]; then
    echo "β οΈ Failed to post log to Port (HTTP $STATUS)"
    echo "::group::Log API Response"
    cat .port_log_response
    echo "::endgroup::"
    fi
    rm -f .port_log_response
fi
}
# ===[ Error Trap ]===
handle_error() {
local EXIT_CODE=$?
local LINE=$1
local MESSAGE="β Script failed at line $LINE with exit code $EXIT_CODE"
echo "$MESSAGE"
post_log "$MESSAGE"
exit $EXIT_CODE
}
trap 'handle_error $LINENO' ERR
# ===[ Token Handling ]===
refresh_token() {
echo "π Requesting new token..."
AUTH_RESPONSE=$(curl -s -X POST "$PORT_API_BASE_URL/v1/auth/access_token" \
    -H "Content-Type: application/json" \
    -d "{\"clientId\": \"$PORT_CLIENT_ID\", \"clientSecret\": \"$PORT_CLIENT_SECRET\"}")
TOKEN=$(echo "$AUTH_RESPONSE" | jq -r '.accessToken')
if [[ -z "$TOKEN" || "$TOKEN" == "null" ]]; then
    echo "β Failed to retrieve token"
    post_log "β Failed to retrieve token"
    exit 1
fi
mkdir -p .cache
echo "{\"token\":\"$TOKEN\", \"timestamp\":$(date +%s)}" > "$CACHE_FILE"
}
get_cached_token() {
if [[ ! -f "$CACHE_FILE" ]]; then
    refresh_token
else
    TIMESTAMP=$(jq -r '.timestamp' "$CACHE_FILE")
    TOKEN=$(jq -r '.token' "$CACHE_FILE")
    NOW=$(date +%s)
    AGE=$((NOW - TIMESTAMP))
    if [[ $AGE -ge $CACHE_TTL_SECONDS ]]; then
    refresh_token
    fi
fi
}
get_cached_token
# ===[ Build properties dynamically ]===
PROPERTIES="{}"
if [[ -n "$LEAD_TIME_BEFORE" || -n "$CYCLE_TIME" ]]; then
PROPERTIES="{"
[[ -n "$LEAD_TIME_BEFORE" ]] && PROPERTIES+="\"lead_time_before\": $LEAD_TIME_BEFORE"
[[ -n "$LEAD_TIME_BEFORE" && -n "$CYCLE_TIME" ]] && PROPERTIES+=", "
[[ -n "$CYCLE_TIME" ]] && PROPERTIES+="\"cycle_time\": $CYCLE_TIME"
PROPERTIES+="}"
fi
ENTITY_PAYLOAD=$(cat <<EOF
{
"identifier": "$ACTION_IDENTIFIER",
"title": "$ACTION_TITLE",
"properties": $PROPERTIES,
"relations": {
    "category": "$CATEGORY_IDENTIFIER"
}
}
EOF
)
post_log "π¦ Creating or updating entity in blueprint 'action'..."
HTTP_STATUS=$(curl -s -w "%{http_code}" -o .entity_response.json \
-X POST "$PORT_API_BASE_URL/v1/blueprints/action/entities?upsert=true&run_id=$PORT_RUN_ID" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "$ENTITY_PAYLOAD")
if [[ "$HTTP_STATUS" == "200" || "$HTTP_STATUS" == "201" ]]; then
post_log "β
 Entity '$ACTION_IDENTIFIER' successfully created/updated"
else
post_log "β Failed to create/update entity. HTTP $HTTP_STATUS"
{
    echo "β Failed to create/update entity. HTTP $HTTP_STATUS"
    if [[ -s .entity_response.json ]]; then
    echo "::group::API Error Response"
    cat .entity_response.json
    echo "::endgroup::"
    jq -r '.message // .error // empty' .entity_response.json || true
    else
    echo "β οΈ No response body received or file is empty."
    fi
} || true
rm -f .entity_response.json
exit 1
fi
rm -f .entity_response.json
Create Port action if missing (click to expand)
#!/bin/bash
set -e
# ===[ Configuration ]===
PORT_API_BASE_URL="https://api.getport.io"
CACHE_FILE=".cache/port_token"
CACHE_TTL_SECONDS=3300
# ===[ Accept Parameters ]===
ACTION_IDENTIFIER="$1"
ACTION_TITLE="$2"
PORT_RUN_ID="$3"
if [[ -z "$ACTION_IDENTIFIER" || -z "$ACTION_TITLE" || -z "$PORT_RUN_ID" ]]; then
echo "β Usage: $0 <actionIdentifier> <actionTitle> <runId>"
exit 1
fi
# ===[ Logging Function ]===
post_log() {
local MSG="$1"
echo "$MSG"
if [[ -n "$TOKEN" && -n "$PORT_RUN_ID" ]]; then
    curl -s -X POST "$PORT_API_BASE_URL/v1/actions/runs/$PORT_RUN_ID/logs" \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    -d "{\"message\": \"$MSG\"}" > /dev/null
fi
}
# ===[ Error Trap ]===
handle_error() {
local EXIT_CODE=$?
local LINE=$1
local MESSAGE="β Script failed at line $LINE with exit code $EXIT_CODE"
echo "$MESSAGE"
post_log "$MESSAGE"
exit $EXIT_CODE
}
trap 'handle_error $LINENO' ERR
# ===[ Token Management ]===
refresh_token() {
AUTH_RESPONSE=$(curl -s -X POST "$PORT_API_BASE_URL/v1/auth/access_token" \
    -H "Content-Type: application/json" \
    -d "{\"clientId\": \"$PORT_CLIENT_ID\", \"clientSecret\": \"$PORT_CLIENT_SECRET\"}")
TOKEN=$(echo "$AUTH_RESPONSE" | jq -r '.accessToken')
if [[ -z "$TOKEN" || "$TOKEN" == "null" ]]; then
    post_log "β Failed to retrieve token from Port"
    exit 1
fi
mkdir -p .cache
echo "{\"token\":\"$TOKEN\", \"timestamp\":$(date +%s)}" > "$CACHE_FILE"
}
get_cached_token() {
if [[ ! -f "$CACHE_FILE" ]]; then
    refresh_token
else
    TIMESTAMP=$(jq -r '.timestamp' "$CACHE_FILE")
    TOKEN=$(jq -r '.token' "$CACHE_FILE")
    NOW=$(date +%s)
    AGE=$((NOW - TIMESTAMP))
    if [[ $AGE -ge $CACHE_TTL_SECONDS ]]; then
    refresh_token
    fi
fi
}
get_cached_token
# ===[ Check for Existing Action ]===
post_log "π Checking if action '$ACTION_IDENTIFIER' exists..."
STATUS_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
-H "Authorization: Bearer $TOKEN" \
"$PORT_API_BASE_URL/v1/actions/$ACTION_IDENTIFIER")
if [[ "$STATUS_CODE" == "200" ]]; then
post_log "β
 Action '$ACTION_IDENTIFIER' already exists. Skipping creation."
elif [[ "$STATUS_CODE" == "404" ]]; then
post_log "β Action not found. Creating '$ACTION_IDENTIFIER'..."
HTTP_STATUS=$(curl -s -w "%{http_code}" -o .port_action_response.json -X POST "$PORT_API_BASE_URL/v1/actions" \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    -d @- <<EOF
{
"identifier": "$ACTION_IDENTIFIER",
"title": "$ACTION_TITLE",
"description": "Auto-created placeholder action",
"trigger": {
    "type": "self-service",
    "operation": "CREATE",
    "userInputs": {
    "properties": {
        "user": {
        "type": "string",
        "format": "user",
        "title": "User",
        "default": {
            "jqQuery": ".user.email"
        },
        "visible": false
        }
    },
    "required": [],
    "order": []
    }
},
"invocationMethod": {
    "type": "WEBHOOK",
    "url": "https://example.com",
    "method": "POST",
    "headers": {
    "RUN_ID": "{{ .run.id }}",
    "Content-Type": "application/json"
    },
    "body": {
    "{{ spreadValue() }}": "{{ .inputs }}",
    "port_context": {
        "runId": "{{ .run.id }}"
    }
    },
    "agent": false,
    "synchronized": true
}
}
EOF
)
if [[ "$HTTP_STATUS" == "200" || "$HTTP_STATUS" == "201" ]]; then
    ID=$(jq -r '.action.identifier' .port_action_response.json)
    post_log "π¦ Successfully created action: $ID"
else
    post_log "β Failed to create action. HTTP status: $HTTP_STATUS"
    cat .port_action_response.json
    exit 1
fi
rm .port_action_response.json
else
post_log "β Unexpected error while checking for action '$ACTION_IDENTIFIER'. HTTP status: $STATUS_CODE"
exit 1
fi 
Create Port automation (click to expand)
#!/bin/bash
set -euo pipefail
# ===[ Configuration ]===
# Uncomment the correct region:
# PORT_API_BASE_URL="https://api.us.port.io"
PORT_API_BASE_URL="https://api.getport.io"
CACHE_FILE=".cache/port_token"
CACHE_TTL_SECONDS=3300
# ===[ Accept Parameters ]===
ACTION_IDENTIFIER="$1"
PORT_RUN_ID="$2"
if [[ -z "$ACTION_IDENTIFIER" || -z "$PORT_RUN_ID" ]]; then
echo "β Usage: $0 <actionIdentifier> <runId>"
exit 1
fi
TITLE="Automation for runs of $ACTION_IDENTIFIER"
AUTOMATION_IDENTIFIER=$(echo "$TITLE" | tr '[:upper:]' '[:lower:]' | tr ' ' '_')
# ===[ Logging Function ]===
post_log() {
local MSG="$1"
echo "$MSG"
if [[ -n "${TOKEN:-}" && -n "$PORT_RUN_ID" ]]; then
    curl -s -X POST "$PORT_API_BASE_URL/v1/actions/runs/$PORT_RUN_ID/logs" \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    -d "{\"message\": \"$MSG\"}" > /dev/null
fi
}
# ===[ Error Trap ]===
handle_error() {
local EXIT_CODE=$?
local LINE=$1
local MESSAGE="β Script failed at line $LINE with exit code $EXIT_CODE"
echo "$MESSAGE"
post_log "$MESSAGE"
exit $EXIT_CODE
}
trap 'handle_error $LINENO' ERR
# ===[ Token Management ]===
refresh_token() {
AUTH_RESPONSE=$(curl -s -X POST "$PORT_API_BASE_URL/v1/auth/access_token" \
    -H "Content-Type: application/json" \
    -d "{\"clientId\": \"$PORT_CLIENT_ID\", \"clientSecret\": \"$PORT_CLIENT_SECRET\"}")
TOKEN=$(echo "$AUTH_RESPONSE" | jq -r '.accessToken')
if [[ -z "$TOKEN" || "$TOKEN" == "null" ]]; then
    post_log "β Failed to retrieve token"
    exit 1
fi
mkdir -p .cache
echo "{\"token\":\"$TOKEN\", \"timestamp\":$(date +%s)}" > "$CACHE_FILE"
}
get_cached_token() {
if [[ ! -f "$CACHE_FILE" ]]; then
    refresh_token
else
    TIMESTAMP=$(jq -r '.timestamp' "$CACHE_FILE")
    TOKEN=$(jq -r '.token' "$CACHE_FILE")
    NOW=$(date +%s)
    AGE=$((NOW - TIMESTAMP))
    if [[ $AGE -ge $CACHE_TTL_SECONDS ]]; then
    refresh_token
    fi
fi
}
get_cached_token
# ===[ Check if automation already exists ]===
post_log "π Checking if automation '$AUTOMATION_IDENTIFIER' exists..."
STATUS_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
-H "Authorization: Bearer $TOKEN" \
"$PORT_API_BASE_URL/v1/actions/$AUTOMATION_IDENTIFIER")
if [[ "$STATUS_CODE" == "200" ]]; then
post_log "β
 Automation '$AUTOMATION_IDENTIFIER' already exists. Skipping creation."
exit 0
elif [[ "$STATUS_CODE" != "404" ]]; then
post_log "β Unexpected HTTP status $STATUS_CODE while checking automation."
exit 1
fi
# ===[ Define properties block with double-escaping for jq expressions ]===
PROPERTIES_JSON=$(cat <<'EOF'
{
"run_id": "{{.event.diff.after.id}}",
"run_url": "https://app.port.io/organization/run?runId={{.event.diff.after.id}}",
"status": "{{.event.diff.after.status}}",
"created_at": "{{.event.diff.after.createdAt}}",
"updated_at": "{{.event.diff.after.updatedAt}}",
"{{if (.event.diff.after.status == \"SUCCESS\") then \"duration\" else null end}}": "{{ (.event.diff.after.createdAt | gsub(\"\\\\.[0-9]+Z$\"; \"Z\") | fromdateiso8601) as $created | (.event.diff.after.updatedAt | gsub(\"\\\\.[0-9]+Z$\"; \"Z\") | fromdateiso8601) as $updated | $updated - $created }}",
"{{if (.event.diff.after.status == \"SUCCESS\" and .event.diff.before.requiredApproval == true) then \"waiting_for_approval_duration\" else null end}}": "{{ (.event.diff.before.createdAt | gsub(\"\\\\.[0-9]+Z$\"; \"Z\") | fromdateiso8601) as $created | (.event.diff.before.updatedAt | gsub(\"\\\\.[0-9]+Z$\"; \"Z\") | fromdateiso8601) as $updated | $updated - $created }}",
"{{if (.event.diff.after.status == \"SUCCESS\") then \"cycle_time\" else null end}}": "{{ (.event.diff.before.updatedAt | gsub(\"\\\\.[0-9]+Z$\"; \"Z\") | fromdateiso8601) as $created | (.event.diff.after.updatedAt | gsub(\"\\\\.[0-9]+Z$\"; \"Z\") | fromdateiso8601) as $updated | $updated - $created }}"
}
EOF
)
# ===[ Create Automation ]===
post_log "π Creating automation '$AUTOMATION_IDENTIFIER'..."
curl -s -X POST "$PORT_API_BASE_URL/v1/actions" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d @- <<EOF
{
"identifier": "$AUTOMATION_IDENTIFIER",
"title": "$TITLE",
"description": "Update action run data in Port after creation",
"trigger": {
    "type": "automation",
    "event": {
    "type": "ANY_RUN_CHANGE",
    "actionIdentifier": "$ACTION_IDENTIFIER"
    }
},
"invocationMethod": {
    "type": "UPSERT_ENTITY",
    "blueprintIdentifier": "action_run",
    "mapping": {
    "identifier": "{{.event.diff.after.id}}",
    "title": "{{.event.diff.after.id}}",
    "properties": $PROPERTIES_JSON,
    "relations": {
        "parent_action": "{{.event.diff.after.action.identifier}}",
        "ran_by_actual_user": "{{.event.diff.after.properties.user}}"
    }
    }
},
"publish": true
}
EOF
post_log "β
 Automation '$AUTOMATION_IDENTIFIER' successfully created."
Set up the newly created action
When executing the self-service action we just created, it creates a new self-service action (if one with the same title doesnβt already exist) with a placeholder backend. To make that action functional, replace its frontend and backend with your own implementation. For detailed self-service action setup instructions, see the documentation.
Create an actions ROI dashboardβ
Dashboards let you observe, track, and communicate insights from your action setup.
You can create dashboards that pull data from the Action, Action categories, and Action run entities.
To create the actions ROI dashboard:
- 
Navigate to the Catalog page of your portal. 
- 
Click on the + Newbutton in the left sidebar.
- 
Select New dashboard and name it Actions ROI. 
- 
Click Create.
A new blank dashboard is available, add widgets to start visualizing the actions ROI insights.
Create an AI agent
One of the widgets includes an AI agent widget, so before we create the widget, we need to create the agent itself.
- 
Go to the AI agents page of your portal. 
- 
Click on + AI Agent.
- 
Toggle JSON modeon.
- 
Copy and paste the following JSON schema: AI Agent configuration (click to expand){
 "identifier": "actions_assistant",
 "title": "Actions assistant",
 "icon": "Details",
 "properties": {
 "description": "Responds to basic queries on actions",
 "status": "active",
 "allowed_blueprints": [
 "action",
 "action_run",
 "actions_categories",
 "_user",
 "_team"
 ],
 "prompt": "Be helpful. Each action run has a team that ran it which is the primary team and a group that ran it which is the primary group. ",
 "execution_mode": "Automatic",
 "conversation_starters": [
 "What action ran the most this week ?",
 "What is the top action? ",
 "What is the category of action with the top savings? "
 ]
 },
 "relations": {}
 }
To learn more about AI agents, refer to the documentation.
Add widgetsβ
In the new dashboard, create the following widgets:
AI Agent widget (click to expand)
- Click + Widgetand select AI Agent.
- Title: Actions AI assistant.
- Select your Agent.
- Click Save.
Total hours saved (click to expand)
- Click + Widgetand select Number chart.
- Title: Total hours saved.
- Description: Lead time.
- Select Aggregated by propertyChart type and choose Action as the Blueprint.
- Select the Total lead time saving (h)Property and choosesumfor the Function.
- Select customas the Unit and inputhoursas the Custom unit
- Click Save.
Total processing time saved (click to expand)
- Click + Widgetand select Number chart.
- Title: Total processing time saved.
- Description: Cycle time.
- Select Aggregated by propertyChart type and choose Action as the Blueprint.
- Select the Total cycle time savings (h)Property and choosesumfor the Function.
- Select customas the Unit and inputhoursas the Custom unit
- Click Save.
Total time waiting for approval (click to expand)
- Click + Widgetand select Number chart.
- Title: Total time waiting for approval.
- Select Aggregated by propertyChart type and choose Action as the Blueprint.
- Select the Total waiting time (h)Property and choosesumfor the Function.
- Select customas the Unit and inputhoursas the Custom unit
- Click Save.