# Non-LLM operations in PAP: the glue agent primitives

> Source: <https://baursoftware.com/blog/non-llm-operations-in-pap-the-glue-agent-primitives>
> Published: 2026-07-06 12:00:00+00:00

PAP ships eight agents that will never make an inference call. Not because they're simple — the Trip Assembler merges flight, hotel, and activity reservations into a single typed itinerary, and the Type Router dispatches to downstream agents by inspecting a schema.org `@type`

field. They skip the model because they don't need one. The work is structural: typed input goes in, a different typed shape comes out, deterministically, every time.

These are glue agents. They're the operations that live between the reasoning steps in an agent pipeline, and they're a first-class primitive in PAP's agent registry.

## What a glue agent looks like

Every agent in PAP, whether it calls an LLM, hits an HTTP endpoint, or runs a pure function, implements the same two-piece interface: `AgentMeta`

declares what the agent is, and `AgentExecutor::execute`

is what it does.

For a glue agent, `AgentMeta`

tells the whole story:

```
AgentMeta {
    name: "Type Router",
    version: "0.1.0",
    provider: "PAP",
    action: "schema:ChooseAction",
    object_types: &["schema:Thing"],
    requires_disclosure: &[],
    returns: &["schema:ChooseAction"],
    configurable_properties: vec![],
    network_required: false,
}
```

Two fields stand out. `requires_disclosure: &[]`

means this agent requests no principal data. It will never see a credential, a calendar entry, or a location, because it never asked for one. `network_required: false`

means no outbound connection is made. These aren't flags you set to be polite; they're the fields the orchestrator reads when it builds the mandate. An agent that declares `requires_disclosure: &[]`

gets a mandate with an empty disclosure scope by construction. There's nothing to leak because there's nothing granted.

The execution side is equally plain. Here's the complete implementation of `TypeRouterExecutor`

:

``` php
fn execute(&self, query: &str) -> Result<Value, TransportError> {
    let input: Value = serde_json::from_str(query)?;

    // Form 1: explicit routing table + data
    if let (Some(routes), Some(data)) = (
        input.get("routes"),
        input.get("data")
    ) {
        let input_type = data
            .get("@type")
            .and_then(|v| v.as_str())
            .unwrap_or("schema:Thing");

        let selected = routes
            .get(input_type)
            .and_then(|v| v.as_str())
            .map(str::to_owned);

        return Ok(json!({
            "@type": "schema:ChooseAction",
            "selectedAgent": selected,
            "matched": selected.is_some(),
            "data": data,
        }));
    }

    // Form 2: bare object — report its type, no routing table
    let input_type = input
        .get("@type")
        .and_then(|v| v.as_str())
        .unwrap_or("schema:Thing");

    Ok(json!({
        "@type": "schema:ChooseAction",
        "inputType": input_type,
        "selectedAgent": null,
        "matched": false,
        "data": input,
    }))
}
```

No async. No network call. No prompt construction. The function reads a `@type`

field, looks it up in a table, and returns a typed `schema:ChooseAction`

the pipeline can act on. The entire reasoning budget for this step is a hash map lookup.

This is what it means for an operation to not need a model: not that it's simple, but that its inputs and outputs have known shapes and the transformation between them is a function, not a judgment.

## The four patterns

PAP's glue agents cover four structural operations. Each maps to a schema.org action type, not by convention, but because these action types already exist in the vocabulary and describe exactly what the operation does.

**Transform — schema:ConvertAction**

A transform agent takes one typed shape and produces a different typed shape. No interpretation, no inference about meaning, just a deterministic mapping from source schema to target schema.

PAP ships three:

| Agent | Input | Output |
|---|---|---|
`JsonToMarkdownExecutor` | any `schema:Thing` | `schema:Text` |
`ItemListToCsvExecutor` | `schema:ItemList` | `schema:Dataset` |
`FlattenArrayExecutor` | `schema:DataFeed` or bare array | `schema:ItemList` |

The CSV converter is a good example of why this matters in practice. An upstream agent returns a `schema:ItemList`

of flight options. A downstream agent needs a table. Without a glue agent, that conversion either goes through an LLM prompt ("format this as CSV") or gets hardcoded into the pipeline. With a glue agent, it's a typed contract: `ItemList`

in, `Dataset`

out, headers inferred from the first element's keys, missing fields emitted as empty strings. Deterministic, testable, zero inference cost.

**Reduce — schema:CombineAction**

A reduce agent merges multiple typed inputs into one. PAP's `TripAssemblerExecutor`

takes an array of reservations (`schema:FlightReservation`

, `schema:LodgingReservation`

, `schema:EventReservation`

) and assembles them into a single `schema:Trip`

, sorted by date, with overlapping segments detected and flagged.

This is the operation most likely to get routed to a model unnecessarily. "Combine these three results" sounds like it needs judgment. It doesn't, not when the inputs are typed. A `schema:Offer`

has a `price`

field. A `schema:AggregateOffer`

has a `lowPrice`

, `highPrice`

, and `offerCount`

. The merge is a fold over known fields, not an interpretation of unstructured text.

**Route — schema:ChooseAction**

A route agent inspects a typed object and returns a dispatch directive: which downstream agent should handle it, based on the object's `@type`

or field values. The routing table is part of the input:

```
{
  "routes": {
    "schema:FlightReservation": "Trip Assembler",
    "schema:LodgingReservation": "Trip Assembler",
    "schema:NewsArticle": "Article Summarizer"
  },
  "data": { "@type": "schema:FlightReservation", "reservationId": "AA123" }
}
```

Output:

```
{
  "@type": "schema:ChooseAction",
  "selectedAgent": "Trip Assembler",
  "inputType": "schema:FlightReservation",
  "matched": true,
  "data": { "..." }
}
```

The pipeline acts on `selectedAgent`

. If `matched`

is false, it falls through to a default. The routing logic is inspectable at any point. It's a JSON table, not a prompt, so you can read it, test it, and change it without touching inference.

**Probe — schema:CheckAction**

A probe agent validates a typed object before it proceeds. PAP ships two: `SchemaValidatorExecutor`

checks structural integrity (`@type`

present, at least one non-type field), and `FieldPresenceExecutor`

checks that specific required fields exist and are non-null.

```
{
  "fields": ["price", "availability", "validThrough"],
  "data": { "@type": "schema:Offer", "price": 299, "availability": "InStock" }
}
```

Output:

```
{
  "@type": "schema:CheckAction",
  "present": ["price", "availability"],
  "missing": ["validThrough"],
  "all_present": false
}
```

A probe that returns `all_present: false`

is a hard stop. The pipeline doesn't proceed to checkout with an offer missing its expiry date. This is the kind of validation that otherwise gets skipped, hardcoded into the consuming agent, or sent to a model to "check if this looks complete." A model that hallucinates `validThrough`

into existence is strictly worse than a probe that fails closed.

## Zero-disclosure by construction

The phrase "zero-disclosure" in PAP has a specific technical meaning. It's not a policy. It's a structural outcome of how mandates are built.

When the orchestrator evaluates an agent for a pipeline step, it reads `requires_disclosure`

from `AgentMeta`

and constructs the mandate's disclosure scope from that list. An LLM agent that needs the user's location and name declares `requires_disclosure: &["schema:location", "schema:name"]`

. The orchestrator surfaces those fields to the principal for approval before the step runs, and the SD-JWT selective disclosure envelope reveals only those two properties.

A glue agent that declares `requires_disclosure: &[]`

gets a mandate with an empty disclosure scope. The orchestrator doesn't ask the principal for approval over data fields because there are none to approve. There is no SD-JWT envelope to construct because there is nothing to disclose. The agent receives exactly what was passed in the pipeline step's typed payload and nothing else.

This means the zero-disclosure property doesn't depend on trusting the glue agent implementation. Even if the `TypeRouterExecutor`

were somehow compromised, it has no path to principal data: no credential was ever included in its mandate, no disclosure scope was ever opened, no ambient context was passed. The isolation is enforced at the protocol layer before the agent code runs.

That's the distinction between policy-based privacy ("this agent promises not to use your data") and structural privacy ("this agent was never given your data"). PAP's glue agents are the latter. Evidence, not faith.

## How registries advertise glue agents

Glue agents aren't special-cased in the registry. They advertise the same `AgentAdvertisement`

as every other agent, signed by the provider's DID, including `action`

, `object_types`

, and `returns`

, and they appear in the same federation index that syncs to connected clients.

What makes them useful in the intent index is exactly the properties that make them cheap to run. When Papillon receives a user's intent and queries the semantic index to find matching agents, it's building a candidate set from the advertised `action`

and `object_types`

fields across every agent in the index: local catalog agents, registry agents from federated peers, and glue agents. The routing and transform agents surface on `schema:ChooseAction`

and `schema:ConvertAction`

queries. The probe agents surface on `schema:CheckAction`

.

This matters because pipelines can be assembled from agents that span multiple registries, with glue agents contributed by any peer that advertises them, without central coordination. A registry that specializes in travel data can advertise a `TripAssemblerExecutor`

that understands its own reservation format. A client that has already routed to that registry's flight search agent can find the compatible reduce agent in the same index sweep, without knowing ahead of time that it exists.

The semantic routing in Papillon's intent index, built on ordvec's two-stage sign-probe with exact cosine rescore, is sophisticated enough to distinguish between a `schema:CombineAction`

that merges trip reservations and one that merges product offers, because the `object_types`

field carries that specificity. The vocabulary does the disambiguation work that would otherwise require another model call.

The result is that glue agents are discoverable the same way capabilities are discoverable: through the registry's advertised schema, not through hardcoded pipeline wiring. A pipeline assembled for one set of agents can pick up a better transform or a stricter probe from a newly-federated registry without any configuration change. The type contract is the interface; the registry is the directory; the intent index is the resolver. No coordinator required.

## What this adds up to

An agent pipeline built with PAP's glue primitives has a property that's hard to get otherwise: every junction between steps is a typed contract you can inspect statically, not a black box you have to trust ran correctly.

The reasoning steps, the ones that actually need a model, are bounded on both sides by typed shapes. The model receives a well-formed `schema:Action`

and returns a well-formed `schema:Thing`

. Before that result moves to the next step, a probe checks it. If it needs to change shape for the consuming agent, a transform handles it. If it needs to go to one of several downstream agents, a route dispatches it. None of those intermediate steps cost inference. None of them carry mandate scope beyond the payload they're explicitly handling.

The inference calls in the pipeline are exactly the ones that need inference. Everything else is a function.
