---
sidebar_label: External fields
toc_max_heading_level: 3
doc_id: b7e3a142-6f04-4d8b-9a2e-c1f83e5b90d7
description: >-
  Enrich specification schemas with live data from your infrastructure,
  resolved at read time through the nullplatform agent.
keywords:
  - external context
  - external fields
  - dynamic schemas
  - additionalKeywords
  - agent
  - notification channel
  - specifications
---

import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';

# External fields

By default, JSON schemas in nullplatform specifications are static: enum values, defaults, and validation rules are defined at design time. The [`additionalKeywords`](/docs/json-ui-schema/json-schema#additional-keywords) feature lets you make those keywords dynamic, but only from data that already lives inside nullplatform (service attributes, application metadata, scopes, and the parent namespace and account).

**External fields** 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](/docs/agent/overview) 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

```mermaid
sequenceDiagram
    participant Client as Client (dashboard, CLI, CI/CD)
    participant API as nullplatform API
    participant Agent as Agent (your infra)

    Client->>API: GET /action_specification/:id?application_id=X
    API->>API: Read specification from DB
    API->>Agent: Sync notification with action and context
    Agent->>Agent: Run handler script
    Agent-->>API: JSON result
    API->>API: Validate against output_schema
    API->>API: Inject result as context.external
    API->>API: Resolve additionalKeywords with jq
    API-->>Client: Specification with dynamic schema values
```

1. A client requests a specification with context parameters (e.g., `application_id`).
2. Nullplatform detects the `external` field on the specification and sends a synchronous notification to the agent.
3. The agent executes a handler script and returns a JSON response.
4. Nullplatform validates the response against the `output_schema`, injects it as `.external` in the jq context, and resolves all `additionalKeywords` expressions.
5. The client receives the specification with dynamic enum values, defaults, and other resolved keywords.

## Prerequisites

Before configuring external fields on a specification, make sure you have:

- The [nullplatform agent installed](/docs/agent/installation) in your infrastructure.
- An [API key with **Agent** and **Ops** roles](/docs/agent/authentication).
- A [notification channel](#set-up-the-notification-channel) configured for external fields (see below).

## Configure external fields 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 fields

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](/docs/api/service-specification-actions-create) endpoint.

<Tabs
defaultValue="ext-ctx-cli"
values={[
{ label: 'CLI', value: 'ext-ctx-cli' },
{ label: 'cURL', value: 'ext-ctx-curl' },
]}>
<TabItem value="ext-ctx-cli">

```bash
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": {}
    }
  }'
```

</TabItem>
<TabItem value="ext-ctx-curl">

```bash
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": {}
    }
  }'
```

</TabItem>
</Tabs>

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:

```json
{
  "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`:

```json
{
  "domain": {
    "type": "string",
    "additionalKeywords": { "enum": ".external.available_domains" }
  },
  "certificate": {
    "type": "string",
    "additionalKeywords": { "default": ".external.default_certificate" }
  }
}
```

## Set up the notification channel

External field resolution uses the nullplatform [notification system](/docs/notifications) to communicate with the agent. You need a dedicated **agent notification channel** for this purpose.

:::warning Separate external fields from infrastructure channels
External field notifications use `source: "service"`, the same source as service infrastructure notifications (create, update, delete). Without proper filtering, your infrastructure channel could receive external field requests and vice versa.

**Create a dedicated channel** for external fields 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 field 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 field 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](/docs/api/notification-channel-create) endpoint.

<Tabs
defaultValue="ext-channel-cli"
values={[
{ label: 'CLI', value: 'ext-channel-cli' },
{ label: 'cURL', value: 'ext-channel-curl' },
]}>
<TabItem value="ext-channel-cli">

```bash
np notification channel create \
  --body '{
    "nrn": "organization=1:account=2:namespace=3:application=4",
    "source": ["service"],
    "description": "External field 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"] }
    }
  }'
```

</TabItem>
<TabItem value="ext-channel-curl">

```bash
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 field 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"] }
    }
  }'
```

</TabItem>
</Tabs>

Key configuration details:

- **`source`**: Must be `["service"]`. External field notifications share this source with service infrastructure notifications. See the [warning above](#set-up-the-notification-channel) about channel separation.
- **`filters`**: Use the `action` field to separate this channel from infrastructure traffic. The example above uses `$in` to explicitly list the external field 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 on `service.*` 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.

:::warning Only JSON in stdout
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:

```bash
#!/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:

```json
{
  "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.

:::note
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 field resolution **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](#set-up-the-notification-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.

```json
{
  "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](/docs/tutorials/external-context-dynamic-values) 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](#set-up-the-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. |

:::tip Use the notification ID
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](/docs/tutorials/external-context-dynamic-values): end-to-end walkthrough from specification to testing
- [Additional keywords](/docs/json-ui-schema/json-schema#additional-keywords): learn about jq expressions and the full context available for dynamic schemas
- [Set up an agent notification channel](/docs/agent/agent-channel): detailed guide for agent channel configuration
- [Action specifications](/docs/services/craft-a-service/service-actions): learn about action specification design and lifecycle
