> ## Documentation Index
> Fetch the complete documentation index at: https://docs.kordio.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Pass facts from your own service

> Compute a fact your side, send it in metadata, compare it in a rule.

Most rules people ask for sound like missing features and are actually one missing fact. "Hold
any payment to a buyer whose account is less than 30 days old" reads like it needs a buyer
model, a join, and a release. It needs a number, computed by the service that already knows it,
sent on the request.

## Before you start

<Snippet file="env-agents.mdx" />

```bash Identifiers theme={"dark"}
export KORDIO_AGENT_ID="7d2a6c15-3f89-4b02-9e64-8a15d7c0b3f2"
export KORDIO_BUDGET_ID="3f1c8a92-5d41-4c8e-9f2b-7a6e1d0c4b83"
```

The amounts here assume the agent's starter policy is disabled, as the
[quickstart](/agents/quickstart) does in step 2. Leave it on and it holds anything over \$100 for
a person before your own rules get a say.

## What a rule can read

A decision is a pure function of the request. Kordio never calls out to your service while it
decides, so a rule reads seven facts Kordio holds (`action_type`, `resource`, `cost_cents`,
`currency`, `agent_id`, `agent_mode`, `budget_id`) and any `metadata.*` dot path you attached.

`metadata` is the extension point. Anything your side can work out before it asks for
authorization, a rule can decide on.

| The fact                                           | Who holds it | How the rule gets it               |
| -------------------------------------------------- | ------------ | ---------------------------------- |
| Amount, counterparty, agent, budget                | Kordio       | Read the field directly.           |
| Buyer account age, order total, seat count, region | You          | Compute it, send it in `metadata`. |

## 1. Send the fact you already have

Your service knows when the buyer signed up. Turn that into a number before the call:

```typescript TypeScript theme={"dark"}
const accountAgeDays = Math.floor(
  (Date.now() - buyer.createdAt.getTime()) / 86_400_000,
);

const res = await fetch("https://api.kordio.io/control/v1/agent/payment_intents", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.KORDIO_AGENT_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    budget_id: process.env.KORDIO_BUDGET_ID,
    amount_cents: 18_000,
    counterparty: "acme-supplies.example",
    idempotency_key: "order-8812-attempt-1",
    metadata: { order_id: "8812", account_age_days: accountAgeDays },
  }),
});
```

```json 201 Created theme={"dark"}
{
  "data": {
    "id": "6f0a3d51-9c27-4b18-8e34-2a5d7b0c9e46",
    "budget_id": "3f1c8a92-5d41-4c8e-9f2b-7a6e1d0c4b83",
    "amount_cents": 18000,
    "currency": "USD",
    "counterparty": "acme-supplies.example",
    "state": "pending",
    "idempotency_key": "order-8812-attempt-1"
  },
  "decision": {
    "outcome": "allowed",
    "rule": null,
    "detail": {},
    "headroom": { "session_remaining_cents": 50000 }
  },
  "cosignature": "eyJhbGciOiJFUzI1NiIsImtpZCI6ImtleV8xIn0..."
}
```

Nothing reads `account_age_days` yet. The fact is on the request and no rule mentions it.

## 2. Compare it in one predicate

Now the feature request becomes a rule. Hold a payment when the buyer is new, and hold it just
as firmly when nobody said how old the buyer is:

```bash theme={"dark"}
curl -s -X POST https://api.kordio.io/control/v1/workspaces/$KORDIO_WORKSPACE/policies \
  -H "Authorization: Bearer $KORDIO_DASHBOARD_TOKEN" \
  -H "Content-Type: application/json" \
  -d "{
    \"agent_id\": \"$KORDIO_AGENT_ID\",
    \"rules\": [
      {
        \"kind\": \"condition\",
        \"effect\": \"require_approval\",
        \"rule_name\": \"new_buyer_needs_a_person\",
        \"action_types\": [\"payment.create\"],
        \"when\": { \"field\": \"metadata.account_age_days\", \"operator\": \"lt\", \"value\": 30 }
      },
      {
        \"kind\": \"condition\",
        \"effect\": \"require_approval\",
        \"rule_name\": \"unknown_buyer_needs_a_person\",
        \"action_types\": [\"payment.create\"],
        \"when\": { \"field\": \"metadata.account_age_days\", \"operator\": \"exists\", \"value\": false }
      }
    ]
  }"
```

```json 201 Created theme={"dark"}
{
  "data": {
    "id": "d93b6a04-2f71-4c85-9b13-8e0d5a7c26f9",
    "agent_id": "7d2a6c15-3f89-4b02-9e64-8a15d7c0b3f2",
    "status": "active",
    "mode": "blocklist",
    "rules": [
      { "rule_name": "new_buyer_needs_a_person", "id": "4c1e8f60-7a29-4d03-b5e8-9f2a1c740db6" },
      { "rule_name": "unknown_buyer_needs_a_person", "id": "e70b5c39-1d84-4f26-a903-6b8e2d5f4107" }
    ],
    "created_at": "2026-08-22T11:20:07Z"
  }
}
```

The same payment, from a buyer who signed up twelve days ago, now waits for a person:

```bash theme={"dark"}
curl -s -X POST https://api.kordio.io/control/v1/agent/payment_intents \
  -H "Authorization: Bearer $KORDIO_AGENT_KEY" \
  -H "Content-Type: application/json" \
  -d "{
    \"budget_id\": \"$KORDIO_BUDGET_ID\",
    \"amount_cents\": 18000,
    \"counterparty\": \"acme-supplies.example\",
    \"idempotency_key\": \"order-8813-attempt-1\",
    \"metadata\": { \"order_id\": \"8813\", \"account_age_days\": 12 }
  }"
```

```json 202 Accepted theme={"dark"}
{
  "data": {
    "id": "8d5c2e70-4b91-4a36-9c07-1f6a3d80b524",
    "amount_cents": 18000,
    "counterparty": "acme-supplies.example",
    "state": "requires_approval",
    "idempotency_key": "order-8813-attempt-1"
  },
  "decision": {
    "outcome": "requires_approval",
    "rule": "new_buyer_needs_a_person",
    "detail": {
      "rule_id": "4c1e8f60-7a29-4d03-b5e8-9f2a1c740db6",
      "matched": { "field": "metadata.account_age_days", "operator": "lt", "value": 30 }
    },
    "headroom": { "session_remaining_cents": 32000 }
  }
}
```

## What to watch when you send facts

**A missing fact never raises.** It resolves to nil and the predicate is false, so the rule
quietly does not fire. That is why the second rule above exists: `exists` is the only operator
that tells "absent" apart from "false".

**Ordered comparisons are numeric.** `gt`, `gte`, `lt` and `lte` accept a number or a string
that parses as one, so `29` and `"29"` both work. Anything else compares as `false` rather than
erroring, which means a typo in a path fails silently.

**Dot paths go as deep as your object.** `metadata.buyer.account_age_days` reads a nested
object, so you can send the shape your service already produces.

**Facts can be compared to each other.** A predicate can take a `value_field` instead of a
`value` and compare two things on the same request, which is how a refund gets capped at the
order it belongs to. See [stop an agent over-refunding](/agents/guides/refund-ceiling).

<Warning>
  Send only what a rule needs to read. `metadata` is stored on the action intent, returned by
  the API, and visible to anyone with `read` on the workspace, so it is the wrong place for card
  numbers, tokens, or anything you would not put in an audit log.
</Warning>

## Metadata decides, decision\_context records

On `POST /control/v1/agent/payment_intents` the two fields look interchangeable and are not.
`decision_context` is provenance: it is stored on the payment intent and never evaluated.
`metadata` is evaluated and, on the payment path, not stored.

|                                                           | Read by rules | Stored on the intent |
| --------------------------------------------------------- | ------------- | -------------------- |
| `metadata` on `/control/v1/agent/actions`                 | Yes           | Yes                  |
| `metadata` on `/control/v1/agent/payment_intents`         | Yes           | No                   |
| `decision_context` on `/control/v1/agent/payment_intents` | No            | Yes                  |

So a payment whose approval hinged on `account_age_days` should carry the number in both:
`metadata` so the rule can hold it, `decision_context` so the record says why.

## Next steps

<CardGroup cols={2}>
  <Card title="Require a person above a threshold" icon="users" href="/agents/guides/approval-threshold">
    What happens to the payment this rule just held.
  </Card>

  <Card title="Stop an agent over-refunding" icon="undo-2" href="/agents/guides/refund-ceiling">
    Compare one field on the request to another.
  </Card>

  <Card title="Writing a policy" icon="scale" href="/agents/policies">
    Every operator, and what each one does to a missing field.
  </Card>

  <Card title="How a decision is reached" icon="shield" href="/agents/concepts">
    Why the first denial wins, and where approvals sit in the order.
  </Card>
</CardGroup>
