External context
By default, JSON schemas in nullplatform specifications are static: enum values, defaults, and validation rules are defined at design time. The additionalKeywords feature lets you make those keywords dynamic, but only from data that already lives inside nullplatform (service attributes, application metadata, scopes).
External context extends this by fetching data from your own infrastructure at the moment the specification is read. A specification declares what data it needs, the nullplatform agent running in your environment resolves it, and the result is injected into the schema context as .external for additionalKeywords expressions to use.
Any client that reads the specification with context parameters (the dashboard, a CI/CD pipeline, or a direct API call) receives a schema enriched with live values like database users, DNS zones, S3 buckets, or any other resource your agent can query.
How it works
- A client requests a specification with context parameters (e.g.,
application_id). - Nullplatform detects the
externalfield on the specification and sends a synchronous notification to the agent. - The agent executes a handler script and returns a JSON response.
- Nullplatform validates the response against the
output_schema, injects it as.externalin the jq context, and resolves alladditionalKeywordsexpressions. - The client receives the specification with dynamic enum values, defaults, and other resolved keywords.
Prerequisites
Before configuring external context on a specification, make sure you have:
- The nullplatform agent installed in your infrastructure.
- An API key with Agent and Ops roles.
- A notification channel configured for external context (see below).
Configure external context on a specification
Add the external field to any action specification or link specification. The field has two required properties:
| Property | Type | Description |
|---|---|---|
action | string | The action name sent to the agent. The agent uses this to decide which handler script to run. |
output_schema | JSON Schema object | Required. Validates the agent's response before injecting it into the context. If validation fails, the schema resolves without external data and the API returns a resolution status. |
Example: action specification with external context
This action specification fetches a list of Postgres users from the agent to populate the owner enum dynamically. This uses the Create a service specification action endpoint.
- CLI
- cURL
np service specification action specification create \
--serviceSpecificationId $service_spec_id \
--body '{
"name": "Create Postgres Database",
"type": "create",
"external": {
"action": "list-postgres-users",
"output_schema": {
"type": "object",
"properties": {
"users": {
"type": "array",
"items": { "type": "string" }
}
},
"required": ["users"]
}
},
"parameters": {
"schema": {
"type": "object",
"properties": {
"db_name": {
"type": "string",
"title": "Database name"
},
"owner": {
"type": "string",
"title": "Database owner",
"additionalKeywords": {
"enum": ".external.users"
}
}
},
"required": ["db_name", "owner"]
},
"values": {}
},
"results": {
"schema": { "type": "object", "properties": {} },
"values": {}
}
}'
curl -X POST "https://api.nullplatform.com/service_specification/$service_spec_id/action_specification" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <token>" \
-d '{
"name": "Create Postgres Database",
"type": "create",
"external": {
"action": "list-postgres-users",
"output_schema": {
"type": "object",
"properties": {
"users": {
"type": "array",
"items": { "type": "string" }
}
},
"required": ["users"]
}
},
"parameters": {
"schema": {
"type": "object",
"properties": {
"db_name": {
"type": "string",
"title": "Database name"
},
"owner": {
"type": "string",
"title": "Database owner",
"additionalKeywords": {
"enum": ".external.users"
}
}
},
"required": ["db_name", "owner"]
},
"values": {}
},
"results": {
"schema": { "type": "object", "properties": {} },
"values": {}
}
}'
When a client reads this action specification with an application_id, the owner field's enum is populated with the actual Postgres users returned by the agent. In the dashboard this renders as a dropdown; via the REST API the resolved schema is returned directly in the response.
Using multiple external values
A single external call can return multiple pieces of data. Reference them in different fields:
{
"external": {
"action": "list-infra-resources",
"output_schema": {
"type": "object",
"properties": {
"available_domains": { "type": "array", "items": { "type": "string" } },
"default_certificate": { "type": "string" },
"vpc_validated": { "type": "boolean" }
},
"required": ["available_domains"]
}
}
}
Then reference each value in additionalKeywords:
{
"domain": {
"type": "string",
"additionalKeywords": { "enum": ".external.available_domains" }
},
"certificate": {
"type": "string",
"additionalKeywords": { "default": ".external.default_certificate" }
}
}
Set up the notification channel
External context resolution uses the nullplatform notification system to communicate with the agent. You need a dedicated agent notification channel for this purpose.
External context notifications use source: "service", the same source as service infrastructure notifications (create, update, delete). Without proper filtering, your infrastructure channel could receive external context requests and vice versa.
Create a dedicated channel for external context and use the action field in the notification context to separate traffic. Infrastructure actions use well-known names like "create", "update", and "delete", while external context actions use custom names you define in the specification (e.g., "list-postgres-users").
Also note that during service creation, the service doesn't exist yet, so context fields like service.specification.slug are not available. If your channel filters depend on these fields, external context notifications won't match. This is not a concern for link creation, where the parent service already exists.
Create the channel
This uses the Create a notification channel endpoint.
- CLI
- cURL
np notification channel create \
--body '{
"nrn": "organization=1:account=2:namespace=3:application=4",
"source": ["service"],
"description": "External context resolution channel",
"type": "agent",
"configuration": {
"api_key": "AAAA.1234567890abcdef1234567890abcdefPTs=",
"command": {
"type": "exec",
"data": {
"cmdline": "path-to-handler/handle-external-context.sh",
"environment": {
"NP_ACTION_CONTEXT": "${NOTIFICATION_CONTEXT}"
}
}
},
"selector": { "environment": "local" }
},
"filters": {
"action": { "$in": ["list-postgres-users"] }
}
}'
curl -X POST 'https://api.nullplatform.com/notification/channel' \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer <token>' \
-d '{
"nrn": "organization=1:account=2:namespace=3:application=4",
"source": ["service"],
"description": "External context resolution channel",
"type": "agent",
"configuration": {
"api_key": "AAAA.1234567890abcdef1234567890abcdefPTs=",
"command": {
"type": "exec",
"data": {
"cmdline": "path-to-handler/handle-external-context.sh",
"environment": {
"NP_ACTION_CONTEXT": "${NOTIFICATION_CONTEXT}"
}
}
},
"selector": { "environment": "local" }
},
"filters": {
"action": { "$in": ["list-postgres-users"] }
}
}'
Key configuration details:
source: Must be["service"]. External context notifications share this source with service infrastructure notifications. See the warning above about channel separation.filters: Use theactionfield to separate this channel from infrastructure traffic. The example above uses$into explicitly list the external context actions this channel should receive. This is safer than excluding infrastructure actions with$nin, because new infrastructure action types added in the future won't accidentally match your channel. Avoid filters onservice.*fields, since those are not available during service creation.cmdline: Points to your handler script. The agent executes this script and captures its stdout as the JSON response.NP_ACTION_CONTEXT: Passes the full notification context to the script via an environment variable.
Write the agent handler
The agent runs the script specified in the channel's cmdline and captures its stdout as the JSON response.
The agent captures everything written to stdout and parses it as JSON. Any non-JSON output (log messages, debug prints, progress indicators) will break the response. Always redirect logging to stderr.
Handler script example
Here's a handler script that routes to different sub-handlers based on the action field:
#!/bin/bash
# Parse the notification context
CONTEXT="$NP_ACTION_CONTEXT"
ACTION=$(echo "$CONTEXT" | jq -r '.action')
# All logging goes to stderr — stdout is reserved for the JSON response
echo "Handling action: $ACTION" >&2
case "$ACTION" in
list-postgres-users)
# Query your infrastructure and return the result as JSON
USERS=$(psql -h "$DB_HOST" -U "$DB_ADMIN" -t -c "SELECT usename FROM pg_user" | \
jq -R -s 'split("\n") | map(select(length > 0) | ltrimstr(" ") | rtrimstr(" "))')
echo "{\"users\": $USERS}"
;;
list-databases)
DBS=$(psql -h "$DB_HOST" -U "$DB_ADMIN" -t -c "SELECT datname FROM pg_database WHERE datistemplate = false" | \
jq -R -s 'split("\n") | map(select(length > 0) | ltrimstr(" ") | rtrimstr(" "))')
echo "{\"databases\": $DBS}"
;;
*)
echo "Unknown action: $ACTION" >&2
echo "{\"error\": \"Unknown action: $ACTION\"}"
exit 1
;;
esac
Rules for handler scripts
| Rule | Reason |
|---|---|
| Only write JSON to stdout | The agent parses all of stdout as a single JSON payload. |
Send all logging to stderr (>&2) | Prevents stdout contamination that breaks JSON parsing. |
Return a JSON object that matches output_schema | If the response doesn't match, the resolution is marked as invalid_response. |
| Handle unknown actions gracefully | Return a clear error message so the specification author can debug. |
Context available to the handler
The notification context passed through NP_ACTION_CONTEXT includes:
{
"action": "list-postgres-users", // from external.action in the specification
"entity_nrn": "organization=1:account=2:namespace=3:application=4",
"application": { "id": 456, "name": "my-app", "nrn": "..." }, // always present
"service": { ... }, // only present when the service already exists (e.g., link creation or update actions)
"link": { ... } // only present when the link already exists (e.g., update actions)
}
The action field matches the value you set in the specification's external.action. The entity_nrn is the NRN of the application or service that triggered the resolution.
When creating a new service, the service field in the context may be empty because the service doesn't exist yet. Your handler should be prepared to work with just the application context. When creating a link, the parent service context is fully available.
Resolution statuses
External context never blocks the specification from being returned. If resolution fails, the schema resolves without .external data and the API response includes an external_resolution field with the status.
| Status | Meaning | Who should fix it |
|---|---|---|
success | Data resolved and injected into the schema. | No action needed. |
no_channel | No notification channel matches the target NRN. | Platform admin: create a channel. |
agent_error | The agent returned an error. | Agent administrator: check the handler script. |
invalid_response | The response doesn't match output_schema, or the agent wrote non-JSON to stdout. | Agent administrator: fix the handler script output. |
timeout | The agent didn't respond in time. | Agent administrator: check agent connectivity and script performance. |
service_unavailable | Internal error in nullplatform. | Contact nullplatform support. |
auth_error | Authentication failure communicating with the notification service. | Contact nullplatform support. |
The external_resolution object is computed on every read request. It's not stored on the specification. It only appears in the response when both conditions are met: the specification has an external field configured, and the request includes context parameters (e.g., application_id). Each request triggers its own resolution and produces its own external_resolution, so two clients reading the same specification at different times will each get an independent result.
{
"id": "action-spec-uuid",
"name": "Create Postgres Database",
"type": "create",
"parameters": { "schema": { ... } },
"external_resolution": {
"status": "invalid_response",
"error": "Agent response does not match output_schema: /users/0 expected string, got object",
"notification_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479"
}
}
When the status is not success, the response includes a notification_id, the ID of the notification that was sent to the agent for that specific request. Use it to trace the resolution through the notification system when debugging failures.
REST consumers should check the external_resolution.status field to detect failures programmatically. The dashboard also displays a contextual alert when resolution fails, so end users know that dynamic values aren't available and can report the issue to the appropriate team.
Supported specification types
You can add the external field to:
- Action specifications (service and link): resolves when the specification is read with context
- Link specifications: resolves when the specification is read with context
The resolution flow is the same for all types. The agent receives the relevant context (application, service, or link) depending on which specification triggered the resolution.
Tutorial
For a complete step-by-step walkthrough, from creating the service specification to testing the resolved schema, see the Populate form fields with live infrastructure data tutorial.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| No dynamic values (no dropdown in UI, or no resolved enum in API) | The specification doesn't have an external field, or context parameters (e.g., application_id) weren't passed in the request. | Verify the external field is set on the specification and that you're passing context parameters. |
no_channel status | No agent channel matches the NRN. | Create a notification channel with empty filters. |
invalid_response with "non-JSON output" | The handler script is writing logs to stdout. | Redirect all logging to stderr (>&2). |
invalid_response with schema error | The agent's response doesn't match output_schema. | Check the handler output against the schema definition. |
timeout status | The handler takes too long or the agent is disconnected. | Check agent connectivity and optimize the handler script. |
| Dropdown shows but with stale data | The agent is returning cached results. | Check if the handler script implements caching and adjust TTL. |
When resolution fails, the external_resolution object in the API response includes a notification_id. You can use this ID to look up the notification in the nullplatform API and inspect the full request/response exchange with the agent.
Next steps
- Tutorial: Populate form fields with live infrastructure data: end-to-end walkthrough from specification to testing
- Additional keywords: learn about jq expressions and the full context available for dynamic schemas
- Set up an agent notification channel: detailed guide for agent channel configuration
- Action specifications: learn about action specification design and lifecycle