> For the complete documentation index, see llms.txt.
Skip to main content

Check out Port for yourself ➜ 

Ship new skills through one golden path

Implement with AI

Send this guide to your coding agent.

Prerequisite: Install Port MCP

Once a registry exists, the next problem is getting new skills into it. This guide builds a single golden path for that: submit a SKILL.md, get a pull request opened for you, and land in the registry as pending until someone reviews it.

We'll build this in three parts:

  1. Prepare the skill blueprint - add the properties a publish request needs to track: where the file will live, who submitted it, and a link to review it.
  2. Create the Publish Skill workflow - a self-service workflow that takes raw SKILL.md content, opens a pull request in your registry repo, and creates the corresponding entity as pending.
  3. Build a Skills Lifecycle Control dashboard - one place to see every pending request and the overall health of publish_status across the registry.

Common use cases

  • Give every developer the same one-step way to propose a skill, instead of each team improvising its own process.
  • Keep every proposed skill under review before it reaches the registry's default branch, without blocking anyone on a platform engineer's availability.
  • See every pending publish request in one dashboard, instead of hunting through open pull requests across repositories.

Prerequisites

This guide assumes you have:

  • The skill blueprint and GitOps mapping from Set up a skills registry.
  • A single Git repository your org treats as the canonical skills registry, already scanned by that GitOps mapping. New skills need to land at a path the mapping already watches (for example, under skills/), or the file won't be picked up once merged. This guide uses GitHub; adapt the pull request step to your Git provider if you use something else.
  • A Port account with admin permissions to edit blueprints, create workflows, and build dashboards.

Step 1: Prepare the skill blueprint

The blueprint from the previous guide already tracks a published skill's content and status. A pending request needs a bit more: where the file is meant to land, who proposed it, and a link to the pull request reviewing it.

  1. Go to the Data model page in Port.

  2. Expand the skill blueprint, click the ... button, and select Edit blueprint.

  3. Click on the {...} Edit JSON button.

  4. Add the following properties under schema.properties:

    New skill blueprint properties (click to expand)
    {
    "path": {
    "title": "Path",
    "type": "string",
    "description": "Repo-relative path to the SKILL.md file, e.g. skills/testing/api-smoke-testing/SKILL.md"
    },
    "author": {
    "title": "Author",
    "type": "string",
    "format": "user",
    "description": "The Port user who submitted this skill for publishing"
    },
    "pr_url": {
    "title": "PR URL",
    "type": "string",
    "format": "url",
    "description": "Link to the pull request reviewing this skill, for submissions not yet on the default branch"
    }
    }
  5. Click Save.

A couple of things are worth calling out:

  • path is what lets the same entity survive the pending-to-published transition. The GitOps mapping from the previous guide computes an entity's identifier from the file's repo-relative path once it's merged. Setting path to that same value while the entity is still pending means the mapping updates this entity instead of creating a duplicate once the file lands on the default branch. We'll rely on this in Step 2.
  • pr_url only matters while a skill is pending. Once merged, fileUrl (from the previous guide's blueprint) points at the file on the default branch, which is what cliInstall and everything else keys off. There's no need to update pr_url after that point.

Step 2: Create the Publish Skill workflow

This workflow takes a SKILL.md's raw content, opens a pull request against your registry repo, and creates a pending skill entity linked to that pull request. It has two halves: a GitHub Actions pipeline that does the actual Git work, and a Port workflow that triggers it and records the result.

Add GitHub secrets

In your skills registry repository, go to Settings > Secrets and variables > Actions and add:

  • PORT_CLIENT_ID and PORT_CLIENT_SECRET - a service account credential, generated from your Port Credentials page under the ... menu. A service account is the right call here, unlike the personal credentials used in the previous guide's SessionStart hook: this pipeline reports a CI job's own result back to Port, it isn't reading catalog data that should be scoped to any one user.

The pipeline also needs permission to create branches and open pull requests. The default GITHUB_TOKEN GitHub provides to every workflow run covers this as long as the workflow declares the right permissions, which the example below does, so no extra GitHub token is required.

Create the GitHub Actions workflow

Create a workflow file under .github/workflows/publish-skill.yml in your registry repo:

publish-skill.yml (click to expand)
publish-skill.yml
name: Publish Skill

on:
workflow_dispatch:
inputs:
skill_name:
required: true
type: string
target_path:
required: true
type: string
skill_content:
required: true
type: string
node_run_id:
required: true
description: "Port workflow node run id, so this pipeline can report its result back"
type: string

permissions:
contents: write
pull-requests: write

jobs:
publish-skill:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Create a branch and commit the skill
id: commit
env:
TARGET_PATH: ${{ inputs.target_path }}
SKILL_CONTENT: ${{ inputs.skill_content }}
SKILL_NAME: ${{ inputs.skill_name }}
run: |
BRANCH="publish-skill/${{ github.run_id }}"
git checkout -b "$BRANCH"
mkdir -p "$(dirname "$TARGET_PATH")"
printf '%s' "$SKILL_CONTENT" > "$TARGET_PATH"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add "$TARGET_PATH"
git commit -m "Publish skill: $SKILL_NAME"
git push origin "$BRANCH"
echo "branch=$BRANCH" >> "$GITHUB_OUTPUT"

- name: Open a pull request
id: pr
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SKILL_NAME: ${{ inputs.skill_name }}
run: |
URL=$(gh pr create \
--title "Publish skill: $SKILL_NAME" \
--body "Opened automatically by the Publish Skill workflow in Port." \
--base main \
--head "${{ steps.commit.outputs.branch }}")
echo "pr_url=$URL" >> "$GITHUB_OUTPUT"

- name: Report the result back to Port
env:
PORT_CLIENT_ID: ${{ secrets.PORT_CLIENT_ID }}
PORT_CLIENT_SECRET: ${{ secrets.PORT_CLIENT_SECRET }}
NODE_RUN_ID: ${{ inputs.node_run_id }}
BRANCH: ${{ steps.commit.outputs.branch }}
PR_URL: ${{ steps.pr.outputs.pr_url }}
run: |
ACCESS_TOKEN=$(curl -s -X POST "https://api.port.io/v1/auth/access_token" \
-H "Content-Type: application/json" \
-d "{\"clientId\": \"$PORT_CLIENT_ID\", \"clientSecret\": \"$PORT_CLIENT_SECRET\"}" \
| jq -r '.accessToken')

curl -s -X PATCH "https://api.port.io/v1/workflows/nodes/runs/$NODE_RUN_ID" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"output\": {\"branch\": \"$BRANCH\", \"pr_url\": \"$PR_URL\"}}"

Both the mkdir/printf step and the curl calls read target_path, skill_content, and the branch/PR values from environment variables rather than interpolating them straight into the shell commands. Skill content is free-form text a user submitted; piping untrusted input straight into a shell command via GitHub Actions' ${{ }} expression syntax is a known script-injection risk, so route it through env instead, as shown above.

Workflow inputs are always strings

GitHub Actions workflow inputs are always strings, which is why node_run_id is typed as string above even though Port's own identifier for it isn't a number.

Build the Port workflow

The workflow has four nodes:

  1. Trigger - a self-service trigger that takes the raw SKILL.md content and an optional target path.
  2. Extract skill metadata - an AI node that reads the SKILL.md frontmatter for the skill's name and description, and proposes a target_path if the user didn't supply one. Parsing YAML frontmatter reliably needs more than a JQ expression can do in one pass, so an AI step is the simplest way to get structured fields out of free-form Markdown.
  3. Dispatch Skill Publish PR - triggers publish-skill.yml in your registry repo through the GitHub integration action.
  4. Create skill entity - creates the corresponding skill entity with publish_status: "pending", once the pull request is open.

To create the workflow:

  1. Go to the Workflows page in Port.

  2. Click + Workflow, name it Publish Skill, then click Confirm.

  3. Click Edit on the workflow.

  4. Click the {...} button to open the JSON editor.

  5. Copy and paste the workflow JSON below to replace the example workflow:

    Publish Skill workflow JSON (click to expand)
    {
    "identifier": "publish_skill",
    "title": "Publish Skill",
    "icon": "Rocket",
    "description": "Publish a new SKILL.md to the skills registry: opens a pull request and creates the corresponding skill entity as pending.",
    "allowAnyoneToViewRuns": true,
    "nodes": [
    {
    "identifier": "trigger",
    "config": {
    "type": "SELF_SERVE_TRIGGER",
    "userInputs": {
    "properties": {
    "skill_content": {
    "title": "SKILL.md content",
    "description": "Full raw content of the SKILL.md file to publish.",
    "type": "string",
    "format": "multi-line"
    },
    "target_path": {
    "title": "Target path in skills repo (optional)",
    "description": "Repo-relative path, e.g. \"skills/my-new-skill/SKILL.md\". Leave empty to let the workflow choose one.",
    "type": "string"
    }
    },
    "required": ["skill_content"],
    "order": ["skill_content", "target_path"]
    }
    }
    },
    {
    "identifier": "extract_skill_metadata",
    "title": "Extract Skill Metadata",
    "config": {
    "type": "AI",
    "systemPrompt": "You extract metadata from SKILL.md files. Read the frontmatter (between --- lines) for the skill's name and description. If target_path is empty, call list_entities on the \"skill\" blueprint (including the \"path\" property) to see where existing skills already live, then propose a path that fits the same directory conventions instead of inventing a flat one. Always return the target_path to use, either the user's or your proposed one.",
    "userPrompt": "SKILL.md content:\n{{ .outputs.trigger.skill_content }}\n\nUser-provided target_path (may be empty): {{ .outputs.trigger.target_path }}",
    "tools": ["list_entities", "list_blueprints"],
    "outputSchema": {
    "type": "object",
    "properties": {
    "skill_name": { "type": "string" },
    "description": { "type": "string" },
    "target_path": { "type": "string" }
    },
    "required": ["skill_name", "description", "target_path"]
    }
    }
    },
    {
    "identifier": "dispatch_publish_pr",
    "title": "Dispatch Skill Publish PR",
    "config": {
    "type": "INTEGRATION_ACTION",
    "installationId": "<your-github-integration-installation-id>",
    "integrationProvider": "github-ocean",
    "integrationInvocationType": "dispatch_workflow",
    "integrationActionExecutionProperties": {
    "org": "your-org",
    "repo": "skills-registry",
    "workflow": "publish-skill.yml",
    "workflowInputs": {
    "skill_name": "{{ .outputs.extract_skill_metadata.response | fromjson | .skill_name }}",
    "target_path": "{{ .outputs.extract_skill_metadata.response | fromjson | .target_path }}",
    "skill_content": "{{ .outputs.trigger.skill_content }}",
    "node_run_id": "{{ .workflowNodeRun.identifier }}"
    },
    "reportWorkflowStatus": true
    }
    }
    },
    {
    "identifier": "create_skill_entity",
    "title": "Create Skill Entity",
    "config": {
    "type": "UPSERT_ENTITY",
    "blueprintIdentifier": "skill",
    "mapping": {
    "identifier": "{{ \"your-org/skills-registry/\" + (.outputs.extract_skill_metadata.response | fromjson | .target_path) }}",
    "title": "{{ .outputs.extract_skill_metadata.response | fromjson | .skill_name }}",
    "properties": {
    "description": "{{ .outputs.extract_skill_metadata.response | fromjson | .description }}",
    "path": "{{ .outputs.extract_skill_metadata.response | fromjson | .target_path }}",
    "author": "{{ .workflowRun.trigger.by.email }}",
    "pr_url": "{{ .outputs.dispatch_publish_pr.pr_url }}",
    "publish_status": "pending"
    }
    }
    }
    }
    ],
    "connections": [
    { "sourceIdentifier": "trigger", "targetIdentifier": "extract_skill_metadata" },
    { "sourceIdentifier": "extract_skill_metadata", "targetIdentifier": "dispatch_publish_pr" },
    { "sourceIdentifier": "dispatch_publish_pr", "targetIdentifier": "create_skill_entity" }
    ]
    }
  6. Replace <your-github-integration-installation-id>, your-org, and skills-registry with your own GitHub integration and registry repo.

  7. Click Save.

create_skill_entity's identifier deliberately mirrors the identifier scheme from the previous guide's GitOps mapping (.__repository.full_name + "/" + .skill.skillMdPath), using the same static org/repo prefix and the same target_path value as the mapping's skillMdPath. That match is what makes the automatic handoff in the next section work.

From pending to published, automatically

Once a reviewer merges the pull request, the file now sits on the registry's default branch, at one of the SKILL.md paths the previous guide's GitHub Ocean mapping already watches. On its next resync, that mapping picks up the file, computes the same identifier this workflow used, and updates the same entity: setting instructions, fileUrl, version, and publish_status: "published", while leaving path, author, and pr_url untouched, since the mapping doesn't mention them.

No approval workflow is required for this transition; it falls out of the mapping you already built. If a reviewer wants to reject a submission instead, closing its pull request on GitHub is enough for now; the entity stays pending since nothing ever merges. See Possible enhancements for a more deliberate approve/reject flow.

Step 3: Build a Skills Lifecycle Control dashboard

With requests flowing in, reviewers need one place to see what's waiting on them instead of tracking pull requests by hand.

  1. Go to the Software catalog page, click the + button in the left sidebar, and select New dashboard.
  2. Name it Skills Lifecycle Control.
  3. Add a pie chart widget:
    • Title: Skills Publish Status.
    • Blueprint: skill.
    • Breakdown property: publish_status.
  4. Add a table widget:
    • Title: Pending Publish Requests.
    • Blueprint: skill.
    • Filter: publish_status equals pending.
    • Shown columns: title, created at, author, PR URL.

Reviewers can click straight through from pr_url to review the pull request on GitHub, and the pie chart gives a standing view of how much of the registry is published versus stuck in review.

Possible enhancements

Enrich the publish self-service form

Right now, every skill lands in the same repo with no owner and no group assignment, so a reviewer has to edit the entity by hand after it's approved. Add a few optional inputs to the trigger to capture that upfront:

Extra trigger inputs (click to expand)
{
"repository": {
"title": "Target repository (optional)",
"description": "Route this skill to a specific repo instead of the shared registry. Leave empty to use the default.",
"type": "string",
"format": "entity",
"blueprint": "githubRepository"
},
"owning_team": {
"title": "Owning team (optional)",
"type": "string",
"format": "team"
},
"groups": {
"title": "Assigned groups (optional)",
"description": "Restrict read access to these groups, as set up in the previous guide.",
"type": "array",
"items": {
"type": "string",
"format": "entity",
"blueprint": "_team"
}
}
}

Two things need to change to actually use these values:

  • dispatch_publish_pr's org/repo would need to fall back to the shared registry when repository is empty, something like {{ .outputs.trigger.repository // "your-org/skills-registry" }} split into its org and repo parts. Adjust the split to match your githubRepository blueprint's identifier convention.
  • create_skill_entity's mapping would set "team": ["{{ .outputs.trigger.owning_team }}"] for ownership and "relations": {"group": "{{ .outputs.trigger.groups }}"} for the group scoping from the previous guide's permission policy, so a skill arrives already scoped instead of needing a follow-up edit.

Putting all three on the trigger asks the submitter to make judgment calls (which team owns this, which groups should see it) that they may not be well placed to make. As an alternative, or in addition, extend the extract_skill_metadata AI node from Step 2 to infer them: the same way it already proposes a target_path when one isn't given, it could suggest an owning team from the submitter's own team membership or the registry's existing conventions, and suggest groups by matching the skill's description against what other skills in each group already cover. Leave the trigger inputs in place as an override; fall back to the AI node's suggestion when they're empty.

Add dedicated approve and reject workflows

Step 2 leans on the GitOps mapping to promote a merged skill, and on a reviewer just closing the pull request to reject one. That works, but it means reviewing happens on GitHub, not in Port, and a rejection leaves no record of why. Two self-service workflows close that gap, scoped to whichever role your org trusts to review submissions (the example below uses Admin, but any role with the right permissions works), both triggered directly from a skill entity (for example, from the dashboard's pending requests table).

Both need a few more blueprint properties first, mirroring pr_url's pattern from Step 1:

Extra skill blueprint properties for approve/reject (click to expand)
{
"approver": {
"title": "Approver",
"type": "string",
"format": "user",
"description": "The Port user who approved this skill's publish request."
},
"approved_at": {
"title": "Approved At",
"type": "string",
"format": "date-time"
},
"rejector": {
"title": "Rejector",
"type": "string",
"format": "user",
"description": "The Port user who rejected this skill's publish request."
},
"rejected_at": {
"title": "Rejected At",
"type": "string",
"format": "date-time"
},
"rejection_cause": {
"title": "Rejection Cause",
"type": "string",
"description": "Reason given when rejecting this skill's publish request."
}
}

Each workflow resolves the skill entity to read its pr_url, acts on the pull request through the GitHub integration action, updates the entity, and optionally notifies the author over Slack:

Approve Skill Publish workflow JSON (click to expand)
{
"identifier": "approve_skill_publish",
"title": "Approve Skill Publish",
"icon": "Merge",
"description": "Approves a pending skill submission: merges its pull request and marks the skill published.",
"allowAnyoneToViewRuns": true,
"nodes": [
{
"identifier": "trigger",
"config": {
"type": "SELF_SERVE_TRIGGER",
"userInputs": {
"properties": {
"skill": {
"title": "Skill",
"type": "string",
"format": "entity",
"blueprint": "skill",
"dataset": {
"combinator": "and",
"rules": [{ "property": "publish_status", "operator": "=", "value": "pending" }]
}
}
},
"required": ["skill"],
"order": ["skill"]
},
"permissions": { "roles": ["Admin"] },
"contexts": [{ "on": "ENTITY", "userInput": "skill" }]
}
},
{
"identifier": "resolve_skill",
"title": "Resolve Skill Entity",
"config": {
"type": "WEBHOOK",
"url": "https://api.port.io/v1/blueprints/skill/entities/{{ .outputs.trigger.skill | @uri }}",
"method": "GET",
"synchronized": true
}
},
{
"identifier": "merge_pr",
"title": "Merge Pull Request",
"config": {
"type": "INTEGRATION_ACTION",
"installationId": "<your-github-integration-installation-id>",
"integrationProvider": "github-ocean",
"integrationInvocationType": "merge_pull_request",
"integrationActionExecutionProperties": {
"org": "your-org",
"repo": "skills-registry",
"prNumber": "{{ .outputs.resolve_skill.response.data.entity.properties.pr_url | split(\"/pull/\") | .[1] }}",
"mergeMethod": "squash"
}
}
},
{
"identifier": "mark_published",
"title": "Mark Skill Published",
"config": {
"type": "UPSERT_ENTITY",
"blueprintIdentifier": "skill",
"mapping": {
"identifier": "{{ .outputs.resolve_skill.response.data.entity.identifier }}",
"properties": {
"fileUrl": "{{ .outputs.resolve_skill.response.data.entity as $e | ($e.identifier | split(\"/\")) as $p | \"https://github.com/\" + $p[0] + \"/\" + $p[1] + \"/blob/main/\" + $e.properties.path }}",
"approver": "{{ .workflowRun.trigger.by.email }}",
"approved_at": "{{ now | todateiso8601 }}",
"publish_status": "published"
}
}
}
},
{
"identifier": "notify_author",
"title": "Notify Author (Slack)",
"config": {
"type": "WEBHOOK",
"url": "https://slack.com/api/chat.postMessage",
"method": "POST",
"headers": {
"Content-Type": "application/json; charset=utf-8",
"Authorization": "Bearer {{ .secrets[\"__SLACK_APP_BOT_TOKEN_<team_id>\"] }}"
},
"body": {
"channel": "<your-skills-channel-id>",
"text": "Skill *{{ .outputs.resolve_skill.response.data.entity.title }}* was approved and merged. Approved by: {{ .workflowRun.trigger.by.email }}"
},
"onFailure": "continue"
}
}
],
"connections": [
{ "sourceIdentifier": "trigger", "targetIdentifier": "resolve_skill" },
{ "sourceIdentifier": "resolve_skill", "targetIdentifier": "merge_pr" },
{ "sourceIdentifier": "merge_pr", "targetIdentifier": "mark_published" },
{ "sourceIdentifier": "mark_published", "targetIdentifier": "notify_author" }
]
}
Reject Skill Publish workflow JSON (click to expand)
{
"identifier": "reject_skill_publish",
"title": "Reject Skill Publish",
"icon": "Delete",
"description": "Rejects a pending skill submission: closes its pull request and marks the skill not published.",
"allowAnyoneToViewRuns": true,
"nodes": [
{
"identifier": "trigger",
"config": {
"type": "SELF_SERVE_TRIGGER",
"userInputs": {
"properties": {
"skill": {
"title": "Skill",
"type": "string",
"format": "entity",
"blueprint": "skill",
"dataset": {
"combinator": "and",
"rules": [{ "property": "publish_status", "operator": "=", "value": "pending" }]
}
},
"rejection_cause": {
"title": "Rejection cause",
"type": "string",
"format": "multi-line"
}
},
"required": ["skill", "rejection_cause"],
"order": ["skill", "rejection_cause"]
},
"permissions": { "roles": ["Admin"] },
"contexts": [{ "on": "ENTITY", "userInput": "skill" }]
}
},
{
"identifier": "resolve_skill",
"title": "Resolve Skill Entity",
"config": {
"type": "WEBHOOK",
"url": "https://api.port.io/v1/blueprints/skill/entities/{{ .outputs.trigger.skill | @uri }}",
"method": "GET",
"synchronized": true
}
},
{
"identifier": "close_pr",
"title": "Close Pull Request",
"config": {
"type": "INTEGRATION_ACTION",
"installationId": "<your-github-integration-installation-id>",
"integrationProvider": "github-ocean",
"integrationInvocationType": "close_pull_request",
"integrationActionExecutionProperties": {
"org": "your-org",
"repo": "skills-registry",
"prNumber": "{{ .outputs.resolve_skill.response.data.entity.properties.pr_url | split(\"/pull/\") | .[1] }}"
}
}
},
{
"identifier": "mark_rejected",
"title": "Mark Skill Not Published",
"config": {
"type": "UPSERT_ENTITY",
"blueprintIdentifier": "skill",
"mapping": {
"identifier": "{{ .outputs.resolve_skill.response.data.entity.identifier }}",
"properties": {
"rejector": "{{ .workflowRun.trigger.by.email }}",
"rejected_at": "{{ now | todateiso8601 }}",
"rejection_cause": "{{ .outputs.trigger.rejection_cause }}",
"publish_status": "not published"
}
}
}
},
{
"identifier": "notify_author",
"title": "Notify Author (Slack)",
"config": {
"type": "WEBHOOK",
"url": "https://slack.com/api/chat.postMessage",
"method": "POST",
"headers": {
"Content-Type": "application/json; charset=utf-8",
"Authorization": "Bearer {{ .secrets[\"__SLACK_APP_BOT_TOKEN_<team_id>\"] }}"
},
"body": {
"channel": "<your-skills-channel-id>",
"text": "Skill *{{ .outputs.resolve_skill.response.data.entity.title }}* was rejected. Cause: {{ .outputs.trigger.rejection_cause }}"
},
"onFailure": "continue"
}
}
],
"connections": [
{ "sourceIdentifier": "trigger", "targetIdentifier": "resolve_skill" },
{ "sourceIdentifier": "resolve_skill", "targetIdentifier": "close_pr" },
{ "sourceIdentifier": "close_pr", "targetIdentifier": "mark_rejected" },
{ "sourceIdentifier": "mark_rejected", "targetIdentifier": "notify_author" }
]
}

Values to configure before using either workflow:

  • <your-github-integration-installation-id>, your-org, and skills-registry, same as Step 2.
  • permissions.roles on the trigger, if your org reviews skills under a role other than Admin.
  • The notify_author step is optional; drop it if you don't use Slack. If you keep it, install the Port Slack app to get a __SLACK_APP_BOT_TOKEN_<team_id> secret, and set channel to your skills review channel's ID. Never paste a raw Slack webhook URL into the workflow JSON; reference it through {{ .secrets[...] }} as shown, the same way webhook nodes reference any other credential.

Since merge_pr's pull request merge is exactly what Step 2 already relies on the GitOps mapping to notice, mark_published here is redundant with that mapping's next resync; it just makes the transition immediate instead of waiting for it, and lets you set fileUrl directly since you already know the file merged.

Build a publish-skill skill

The find-skills skill from the previous guide only helps a developer find and install an existing skill. Extend it, or add a sibling skill, that recognizes the opposite intent: a developer says they've written a new skill, or asks to share one with the team.

At a high level, this skill would:

  1. Run find-skills' own duplicate check first (its SKILL.md, embedded in the previous guide, already covers checking for an existing or overlapping skill before drafting a new one), so it doesn't offer to publish something that already exists.
  2. Call list_self_service_triggers (filtered to something like query: "publish") to confirm the exact workflow and node identifiers instead of hardcoding publish_skill, since these can be renamed.
  3. State what it's about to publish and get an explicit go-ahead, the same rule find-skills follows before installing anything.
  4. Call trigger_run with the workflow's identifier, nodeIdentifier, and inputs matching the trigger's schema:
{
"type": "WORKFLOW",
"identifier": "publish_skill",
"nodeIdentifier": "trigger",
"inputs": {
"skill_content": "<the new SKILL.md's full content>"
}
}
  1. Report back the pull request link the workflow returns, so the developer can follow up on review status themselves.

See Expose workflows as tools for the full mechanics of list_self_service_triggers and trigger_run.

Support skills with references and assets

This guide's workflow only ever submits one file. Many real skills ship more than that: a references/ folder with extra docs loaded on demand, or an assets/ folder with templates and files used in outputs, alongside SKILL.md itself. Port's skill blueprint already has a documented shape for this (see Ingest skills from Git): references and assets are array properties, each item shaped {path, content, description?}.

To publish a skill with extra files, extend the trigger with two more optional inputs in that same shape:

Extra trigger inputs for references and assets (click to expand)
{
"references_files": {
"title": "Reference files (optional)",
"type": "array",
"items": {
"type": "object",
"properties": {
"path": { "type": "string", "description": "Path relative to the references/ folder" },
"content": { "type": "string" }
},
"required": ["path", "content"]
}
},
"assets_files": {
"title": "Asset files (optional)",
"type": "array",
"items": {
"type": "object",
"properties": {
"path": { "type": "string", "description": "Path relative to the assets/ folder" },
"content": { "type": "string" }
},
"required": ["path", "content"]
}
}
}

Two things follow from that:

  • publish-skill.yml needs to loop over both arrays and write each item to references/<path> or assets/<path> inside the skill's target directory, alongside SKILL.md, before committing.
  • create_skill_entity should set the references and assets properties directly from these inputs, rather than waiting for the GitOps mapping to pick them up after merge. The mapping's own docs note that each file event replaces the whole array, so a skill with several reference or asset files can lose entries if they merge in separate pull requests; setting the full arrays once, from the same submission that already has all the content in hand, avoids that entirely.

Continue building your skills registry

This guide gets new skills flowing into the registry through one reviewed path. From here:

  • Set up a skills registry: the skill blueprint and GitOps mapping this guide builds on.
  • AI node: how the metadata-extraction step works.
  • GitHub integration action: the full set of GitHub actions a Port workflow can call, including dispatching a workflow and managing pull requests.
  • Track & manage runs: the API this guide's GitHub Actions pipeline uses to report its result back to Port.
  • Expose workflows as tools: how list_self_service_triggers and trigger_run let an agent invoke this workflow directly, referenced in the publish-skill enhancement above.
  • Skills registry: the file-kind GitOps mapping for references and assets, referenced in the multi-file enhancement above.