Skip to content

ECO-639: Document the X-Agent-Info attribution header - #1144

Draft
wkoutre wants to merge 1 commit into
Uniswap:mainfrom
wkoutre:nickkoutrelakos/eco-639-x-agent-info-publish-in-dev-docs-llmstxt
Draft

ECO-639: Document the X-Agent-Info attribution header#1144
wkoutre wants to merge 1 commit into
Uniswap:mainfrom
wkoutre:nickkoutrelakos/eco-639-x-agent-info-publish-in-dev-docs-llmstxt

Conversation

@wkoutre

@wkoutre wkoutre commented Aug 3, 2026

Copy link
Copy Markdown

Linear: ECO-639

Summary

Adds a Swapping API page documenting X-Agent-Info, an optional request header that lets an integration declare its Trading API calls were made by an AI agent rather than a person, so agent-driven volume can be measured separately.

The new page content/trading/swapping-api/agent-attribution.mdx covers the three-field schema (decision_origin, integration_name, version), the rules that mark a header malformed, and a cURL plus TypeScript example. meta.json adds it to the nav after Integration Guide, and short cross-links point to it from integration-guide.mdx, common-errors.mdx, and uniswap-ai/overview.mdx.

Docs are the adoption mechanism here rather than an afterthought: the integrations this header exists to measure are largely built by agents, and an agent wiring up the Trading API reads the docs.

Type of change

  • New content (guide, page, code example)
  • Update to existing content

How has this been verified?

Checked the documented rules against the shipped parser, packages/lib/golang/middlewares/server/xagentinfo.go on Uniswap/backend main. Both worked examples parse clean against it. meta.json is valid JSON and every added link resolves to a real heading.

Anything else reviewers should know?

Not ready to merge. The page's "Confirming it was received" section documents an x-agent-info-status response header that does not exist: it lived in Uniswap/backend#11003, which was closed unmerged and superseded by #11941 (gateway parser only). git grep x-agent-info-status on backend main returns nothing. Several parse rules are also missing or stated in the wrong unit. Details are in the review comments on this PR.

Adds a dedicated page covering the optional X-Agent-Info request
header (decision_origin, integration_name, version), the rules that
mark it malformed, and the x-agent-info-status response contract used
to confirm it parsed. Cross-linked from the swapping API integration
guide, troubleshooting page, and the Uniswap AI overview.

ECO-639
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown

Thanks for contributing to Uniswap Docs! 🦄

Quick heads up on how this repo works: the docs you see on developers.uniswap.org are built from a separate source repo. When we accept a change here, we port it over there, so your fix might show up on the live site before it lands in this repo.

Updates to this repo come in batches on a regular cadence. When yours is included, you'll be credited as a co-author on the commit, so the contribution counts on your GitHub profile and in Uniswap/docs.

We review every PR but can't merge everything. Either way, someone from the docs team will follow up here once we've taken a look.

One legal bit: by submitting a PR you agree your contribution is licensed under the repo's MIT license.

@wkoutre wkoutre left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed every documented rule against the shipped parser on Uniswap/backend main (f839c7996d) — packages/lib/golang/middlewares/server/xagentinfo.go and packages/services/entry-gateway-go/src/libs/apikeymetrics/emitter.go.

The headline: this page was written against Uniswap/backend#11003, which was closed unmerged on 2026-08-18. What actually shipped is #11941, a Go-only gateway parser with no response header and no TypeScript parser. So the request-header half of the page is largely accurate, and the response-contract half documents behavior that does not exist. Details in the inline comments.

What matched the parser

  • 1024-byte cap on the raw value, checked before parsing — matches (unit wording aside).
  • decision_origin must be exactly autonomous or human_mediated, case-sensitive — matches the literal constants.
  • Payload must be a JSON object; arrays, scalars and null are malformed — matches.
  • Unknown keys dropped rather than rejected — matches.
  • Optional fields present but not strings are malformed, including explicit null — matches, and the parser goes out of its way to make null distinguishable from absent.
  • Empty header value is absent, not malformed — matches.
  • A malformed header carries no payload and never echoes input — matches.
  • Both worked examples parse to ok.

What did not

Enumerated inline: the response header does not exist; the printable-US-ASCII rule, the authenticated-key gate, and the duplicate-header-line joining are all absent from the page; the 256 cap is UTF-16 code units rather than characters; the disallowed-character set is incomplete; free-text fields land in a warehouse with no guidance on what not to put in them.

llms.txtMedium, unanchorable. The ticket's "Done when" requires the content to reach llms.txt, and no such file exists in this repo. The PR description infers the private publishing pipeline regenerates it from content/ the way the archived Docusaurus setup did, which is plausible but unverified from here. Since the whole premise of the ticket is that agents discover this header through agent-readable indexes, "probably generated downstream" is a thin basis for calling the distribution requirement done. Worth one question to whoever owns that pipeline before this is marked ready — a confirmed yes closes the criterion, and a no means this PR does not actually satisfy the ticket.

House conventions — checked and clean, no findings. <Callout title="…" type="info"> matches existing usage (type="info" appears 37 times across content/), tables are normal in the Trading pages, the /docs/trading/... link prefix matches every neighbouring link, and frontmatter shape matches. The nav insertion in meta.json is valid JSON and sits sensibly after Integration Guide. Giving this its own page rather than folding it into the integration guide was the right call at this size.

Recommendation. Keep it in draft. The cleanest split is to ship the request-header half now — it is genuinely useful and mostly correct — with the parse rules corrected, and hold the response contract until a backend PR actually lands x-agent-info-status. That change currently has no owner and no open PR, which is worth surfacing on the project rather than leaving inside a docs ticket. I have updated ECO-639 to record the sequencing.

Not flagging anything as blocking beyond that; no REQUEST_CHANGES since the fix depends on a scope decision that is yours.

Comment on lines +46 to +76
## Confirming it was received

Because the request succeeds regardless of whether `X-Agent-Info` parsed, check the `x-agent-info-status` response header to confirm your header was actually recognized:

- **`x-agent-info-status: malformed`** — the header was received but failed one of the checks above and was dropped. This value is a fixed string; it never echoes anything from your request.
- **No `x-agent-info-status` header at all** — this happens when your `X-Agent-Info` header parsed successfully, when you didn't send one, or when the request's response wasn't itself successful (the diagnostic header is only stamped on successful responses). If you're debugging a request that also failed for an unrelated reason (rate limiting, request validation, no route found), the absence of this header doesn't confirm your `X-Agent-Info` header was fine — retry against a request that otherwise succeeds. Attribution is a side channel: on a successful response, a working integration looks identical, from the response alone, to one that sent nothing.

```typescript
const response = await fetch('https://trade-api.gateway.uniswap.org/v1/quote', {
method: 'POST',
headers: {
'x-api-key': 'YOUR_API_KEY',
'Content-Type': 'application/json',
'X-Agent-Info': JSON.stringify({
decision_origin: 'autonomous',
integration_name: 'my-trading-bot',
version: '1.4.0',
}),
},
body: JSON.stringify({
/* ...quote request... */
}),
});

if (response.headers.get('x-agent-info-status') === 'malformed') {
// Received but dropped — check field names, decision_origin value, and length limits above.
console.warn('X-Agent-Info was sent but not recognized.');
}

const quote = await response.json();
```

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Urgent — this entire section documents a response header that does not exist.

x-agent-info-status was implemented in Uniswap/backend#11003, which was closed unmerged on 2026-08-18 and superseded by #11941. #11941 is Go-only and adds the gateway parser and the BigQuery field; it writes no response header. Its own description says "Go only, four files."

Verified against backend main (f839c7996d):

$ git grep -c "x-agent-info-status"
rc=1          # zero matches, whole tracked tree

$ git grep -iln "x-agent-info\|XAgentInfo"
packages/lib/golang/middlewares/server/xagentinfo.go
packages/lib/golang/middlewares/server/xagentinfo_test.go
packages/lib/golang/pubsub/eventspb/events.pb.go
packages/lib/pubsub/gen/events_pb.ts
packages/services/entry-gateway-go/src/libs/apikeymetrics/emitter.go
packages/services/entry-gateway-go/src/libs/apikeymetrics/emitter_test.go

None of those writes a response header — emitter.go only builds and publishes the metrics event, and git grep "Header().Set" across both paths returns nothing.

Consequence, and it is the bad kind: an integrator follows the copyable TypeScript at line 70, ships response.headers.get('x-agent-info-status'), and gets null on every request. Since null is documented at line 51 as "your header parsed successfully," a completely malformed header reads as confirmed working. The page hands them a check that returns a false pass 100% of the time, which is strictly worse than telling them there is no way to confirm.

The claim at line 51 that the header "is only stamped on successful responses" is also unverifiable — that gating existed only in the closed PR.

Options: drop the section and say plainly that there is no way to confirm parsing today, or hold the page until a backend PR lands the response header. Either way the frontmatter description on line 3 needs the same treatment.

One thing that will not be a problem once it does land: CORS needs no change. entry-gateway-go unions the response's actual header names into Access-Control-Expose-Headers per request (src/libs/cors/cors.go, exposedHeaders) rather than sending the * wildcard browsers ignore on credentialed responses, so the header is readable cross-origin by construction. Worth a sentence when the section comes back.

Comment on lines +38 to +42
- The raw header value is larger than **1024 bytes** (UTF-8), measured before parsing.
- The value isn't valid JSON, or is valid JSON that isn't a plain object (an array, string, number, boolean, or `null`).
- `decision_origin` is missing, or is anything other than exactly `autonomous` or `human_mediated`.
- `integration_name` or `version` is present but isn't a string, or exceeds 256 characters.
- `integration_name` or `version` contains control characters or unpaired surrogate code points.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High — the malformed list is missing the rule integrators are most likely to hit, and the one that is hardest to guess.

The raw header value must be printable US-ASCII (0x20–0x7E). Any byte outside that range marks the header malformed, checked before the JSON is even parsed. From xagentinfo.go:

if !isPrintableASCII(raw) {
    return XAgentInfoResult{ParseStatus: XAgentInfoMalformed}
}

It is load-bearing rather than stylistic — the function's own comment explains that Node decodes header bytes as latin1 while Go keeps UTF-8, so a raw é gives the two parsers different strings. Non-ASCII can still reach a field, but only as a JSON \u escape.

Consequence: an integrator sets integration_name to "Café Trading" or a product name with an emoji or a curly quote, gets malformed, and reads this list. Nothing here explains it. Every rule on the page passes, so they conclude it is a length problem and start truncating a 12-character string. This is exactly the debug-without-asking-anyone case the ticket names as the bar.

Two more rules are absent and worth a line each:

Parsing only runs for a request that authenticated with an API key. emitter.go gates the parse on attributed — an introspected API key — not on header presence. Session and anonymous traffic never parses the header at all. Someone testing against an unauthenticated endpoint will see nothing and have no idea why.

Duplicate X-Agent-Info header lines are joined with ", ", not resolved to the first (ParseXAgentInfoValues). Two lines almost always produce invalid JSON, so {"decision_origin":"autonomous"}, {"version":"1.0"} is malformed. An HTTP client that appends rather than replaces a header hits this silently.

Comment on lines +21 to +24
| `integration_name` | string | No | Name of your integration or agent, e.g. `my-trading-bot`. Up to 256 characters. |
| `version` | string | No | Version identifier for your integration. Up to 256 characters. |

Any other keys in the object are silently dropped rather than rejected, so it's safe to reuse an object that carries additional fields for your own purposes — only the three fields above are ever attributed.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High — security. integration_name and version are free text written to a warehouse row per request, and nothing on this page says what must not go in them.

The values land in InternalApiKeyMetrics.x_agent_info and fan out to BigQuery (and ClickHouse, once Uniswap/backend#11973 merges). They are retained, queryable, and joined against wrapped_id / organization_id / analytics_id in the same row — so anything identifying dropped into these fields becomes durably linked to a customer identity.

Line 24 makes it worse rather than neutral: telling an integrator it is "safe to reuse an object that carries additional fields for your own purposes" reads as an invitation to pass their existing session or user context object straight through. The unknown-key claim is accurate — xAgentInfoFields decodes only the three known keys and drops the rest — but "the extra keys are dropped" is a statement about our parsing, not about what the integrator should be putting in an outbound header in the first place. Nothing stops them putting a user id in integration_name, which is the field that is kept.

This audience makes it sharper than usual. An agent reading this page will follow the suggestion literally, and templating a wallet address or an end-user id into integration_name is a one-line change for it.

Suggest replacing line 24 with something closer to: unknown keys are dropped, but send only the three fields — these values are stored and analyzed, so they must be stable, non-identifying strings describing the software, never a user id, wallet address, session token, or anything derived from an end user. Naming the categories matters more than a generic "do not send sensitive data"; a generic warning is the kind an agent classifies its own payload as exempt from.

Worth noting the two nearby guarantees the parser genuinely gives, since they are the reassuring half of the same story: a malformed header is never echoed anywhere (ParseXAgentInfo surfaces no raw input in logs, errors, or metric tags), and only the re-serialized known keys ever reach the event.

Comment on lines +21 to +22
| `integration_name` | string | No | Name of your integration or agent, e.g. `my-trading-bot`. Up to 256 characters. |
| `version` | string | No | Version identifier for your integration. Up to 256 characters. |

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium — "Up to 256 characters" is the wrong unit. The parser counts UTF-16 code units, so an astral character (emoji, some CJK extensions) costs two, not one.

if utf16Len(*field.value) > maxXAgentInfoFieldLength {

utf16Len's comment pins why the distinction is real rather than pedantic — it gives worked counterexamples in both directions, and both are covered by TestParseXAgentInfo_FieldCapCountsUTF16Units: 200 ASCII plus 30 escaped astral pairs is 260 units and is rejected, while a character count of 230 would admit it.

Practically this is JavaScript's String#length, which is the framing most integrators can act on: '…'.length <= 256. A Python or Go integrator counting characters natively will disagree with us on exactly the inputs near the boundary, which is the worst place to be wrong.

Same fix in the line 41 bullet.

- The value isn't valid JSON, or is valid JSON that isn't a plain object (an array, string, number, boolean, or `null`).
- `decision_origin` is missing, or is anything other than exactly `autonomous` or `human_mediated`.
- `integration_name` or `version` is present but isn't a string, or exceeds 256 characters.
- `integration_name` or `version` contains control characters or unpaired surrogate code points.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium — the disallowed-character set is stated loosely and misses two entries.

hasDisallowedFieldChars rejects: C0 controls (≤ 0x1F), DEL (0x7F), C1 controls (0x80–0x9F), U+2028 and U+2029 (the Unicode line separators), and U+FFFD (the replacement character). "Unpaired surrogate code points" is a reasonable plain-English gloss on the U+FFFD rule — an escaped lone surrogate decodes to U+FFFD — but it silently drops the case where the integrator escaped a literal U+FFFD themselves, which is also rejected.

Both additions have a real trigger: U+2028/U+2029 turn up in text scraped from PDFs and in some CMS exports, and U+FFFD is what a lossy re-encode leaves behind. An integrator whose integration_name came from either source gets malformed from a string that looks completely ordinary on screen.

Given the printable-ASCII rule from the other comment, it may read better to collapse all of this into one bullet: the field must be plain printable ASCII, and any non-ASCII must arrive as a \u escape and must not be a control character, a line separator, a surrogate, or the replacement character.


A header is dropped (marked malformed) rather than rejected outright if any of the following hold. The request still succeeds either way — see [Confirming it was received](#confirming-it-was-received) below for how to tell the difference.

- The raw header value is larger than **1024 bytes** (UTF-8), measured before parsing.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low — "(UTF-8)" is misleading here. The cap is len(raw) on the raw header string, i.e. bytes on the wire, and it is checked before any decoding — the code comment is explicit that it "bounds what was received."

Since the printable-ASCII rule rejects every byte above 0x7E anyway, the value can never contain multi-byte UTF-8 in the first place, so invoking UTF-8 suggests a multi-byte encoding is in play when it cannot be. "1024 bytes, measured on the raw header value before parsing" is both shorter and exactly right.

The "measured before parsing" half is correct and worth keeping — it is what tells an integrator that JSON whitespace and escapes count toward the cap.

-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-H 'X-Agent-Info: {"decision_origin":"autonomous","integration_name":"my-trading-bot","version":"1.4.0"}' \
-d '{"tokenIn":"0x...","tokenOut":"0x...","amount":"1000000",...}'

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low — the request body in this example is not valid JSON: the trailing ,... inside the object means a copy-paste of this command 400s before X-Agent-Info is ever evaluated.

The elision is obviously intentional and this convention is common, but it undercuts the page's specific purpose. Someone debugging attribution runs this exact command to isolate the header, gets a 400, and now has two unknowns instead of one.

The X-Agent-Info value on line 30 is correct — I ran it against the parse rules: 84 raw bytes, printable ASCII throughout, a JSON object, decision_origin exactly autonomous, both optional fields short strings. Parses to ok. Same for the JSON.stringify payload in the TypeScript example. The header half of both examples is solid; it is only the surrounding body that is uncopyable.

Either use a minimal but real body, or replace the placeholder with a comment line so it does not read as JSON.


The API is specific about request header validation. In particular, ensure that your `accept` and `content-type` headers only include the value `application/json`. For a complete example of properly formatted request headers, see the authentication section of the [Developer Dashboard](https://developers.uniswap.org/dashboard).

If you're sending the optional `X-Agent-Info` attribution header and it isn't being picked up, check the response for an `x-agent-info-status: malformed` header — see [Agent Attribution](/docs/trading/swapping-api/agent-attribution#confirming-it-was-received) for the full set of rules that make the header malformed.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High — this is the troubleshooting entry point, and it sends the reader to look for a response header that is never sent. See the Urgent comment on agent-attribution.mdx:46-76.

The failure is sharper here than on the page itself. Someone reaches this line precisely because their header "isn't being picked up," follows it, finds no x-agent-info-status header, and the linked section tells them that means it parsed fine. The troubleshooting path terminates in a confident wrong answer about the exact thing they came to debug.

Once the response header exists this cross-link is the right one. Until then it should point at the malformed-rules section instead — that content is real and actionable — or come out with the section it references.

The placement in the Headers block and the link format both match the surrounding page, so nothing else to change here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants