Why it exists
Standard SPARQL JSON repeats the datatype on every binding in every row. For a 1000-row result with 5 variables, that's 5000 redundant type strings. In a context window, this wastes tokens.
Fluree's Typed JSON format ({"@value": 30, "@type": "xsd:long"}) solves the ambiguity problem but makes every value an object — also token-expensive for LLMs that mostly just need the value.
Agent JSON takes a different approach: declare types once in a schema header, then use bare JSON primitives in the rows. This gives the LLM type information when it needs it (check the schema) without paying per-row overhead.
It also adds byte-budget truncation. LLMs have finite context windows, and the relevant constraint is bytes (which correlate with tokens), not row count. A query that returns 10 rows of 10KB each is more expensive than 1000 rows of 10 bytes. The byte budget lets the server stop before overflowing the context, and the resume field provides a ready-to-execute SPARQL query to get the next page.
Requesting Agent JSON
Set the Accept header:
Accept: application/vnd.fluree.agent+json
Optionally set a byte budget:
Fluree-Max-Bytes: 32768
Example
curl -X POST http://localhost:8090/v1/fluree/query \
-H "Content-Type: application/sparql-query" \
-H "Accept: application/vnd.fluree.agent+json" \
-H "Fluree-Max-Bytes: 32768" \
-d '
PREFIX schema: <http://schema.org/>
SELECT ?name ?age
FROM <mydb:main>
WHERE {
?person schema:name ?name .
?person schema:age ?age .
}
'
Response structure
Complete response (no truncation)
{
"schema": {
"?name": "xsd:string",
"?age": "xsd:integer",
"?s": "uri"
},
"rows": [
{"?name": "Alice", "?age": 30, "?s": "ex:alice"},
{"?name": "Bob", "?age": 25, "?s": "ex:bob"}
],
"rowCount": 2,
"t": 5,
"iso": "2026-03-26T14:30:00Z",
"hasMore": false
}
Truncated response (byte budget exceeded)
{
"schema": {
"?name": "xsd:string",
"?age": "xsd:integer"
},
"rows": [
{"?name": "Alice", "?age": 30},
{"?name": "Bob", "?age": 25}
],
"rowCount": 2,
"t": 5,
"iso": "2026-03-26T14:30:00Z",
"hasMore": true,
"message": "Response truncated due to size limit of 32768 bytes. Use the query below to retrieve the next batch.",
"resume": "SELECT ?name ?age FROM <mydb:main@t:5> WHERE { ?s schema:name ?name ; schema:age ?age } OFFSET 2 LIMIT 100"
}
Envelope fields
| Field | Type | When present | Description |
|---|---|---|---|
schema | object | Always | Maps each variable to its XSD datatype. Declared once for all rows. |
rows | array | Always | Array of {variable: value} objects using native JSON types. |
rowCount | integer | Always | Number of rows in this response. |
t | integer | Single-ledger queries | Transaction number the query ran against. Omitted for multi-ledger queries (each ledger has its own timeline). |
iso | string | Always | ISO 8601 wallclock timestamp at query time. |
hasMore | boolean | Always | Whether additional rows exist beyond what was returned. |
message | string | When truncated | Human-readable explanation. |
resume | string | When truncated, single-FROM only | SPARQL query with @t: time-pinning and OFFSET, ready to execute for the next page. |
Schema types
The schema object maps each variable to its datatype:
| Schema value | Meaning |
|---|---|
"xsd:string" | String literal |
"xsd:integer" | Integer |
"xsd:long" | Long integer |
"xsd:double" | Double-precision float |
"xsd:decimal" | Decimal |
"xsd:boolean" | Boolean |
"xsd:date" | Date |
"xsd:dateTime" | DateTime |
"uri" | IRI reference |
When a variable has mixed types across rows, the value is an array:
"?value": ["xsd:string", "xsd:integer"]
Value encoding
Values use native JSON types for anything the JSON type system can represent unambiguously:
| RDF type | JSON encoding | Example |
|---|---|---|
xsd:string | JSON string | "Alice" |
xsd:integer, xsd:long | JSON number | 30 |
xsd:double, xsd:decimal | JSON number | 1.68 |
xsd:boolean | JSON boolean | true |
IRI (uri) | JSON string (compacted via @context) | "ex:alice" |
Types that JSON can't represent natively get an inline annotation:
{"@value": "2024-05-15", "@type": "xsd:date"}
{"@value": "Alicia", "@language": "es"}
The distinction: the schema header tells you the type of a variable across all rows. Inline annotations appear only on individual values when the type would otherwise be ambiguous.
Byte budget
When Fluree-Max-Bytes is set, the formatter serializes rows one at a time. When cumulative serialized row data exceeds the budget, it stops and sets hasMore: true.
The budget covers row data only — schema, envelope fields, and metadata are excluded.
When truncation occurs:
messageexplains what happened.resume(for single-FROM queries) contains a SPARQL query with:@t:pinning to the same transaction number, so the next page sees identical data even if new transactions have committed.OFFSETequal to this response'srowCount.LIMITset to 100 (default batch size).
The caller executes the resume query to get the next page. Repeat until hasMore is false.
Why @t: pinning matters
Without time-pinning, new transactions between page requests could shift rows — causing duplicates or gaps. The resume query pins to the original transaction number so pagination is consistent.
Multi-ledger behavior
When a query has multiple FROM clauses:
tis omitted — there's no single transaction number that covers all ledgers.resumeis omitted — time-pinning would require@t:on eachFROMclause individually, and the server doesn't know the original query's intent well enough to generate this.message(when truncated) suggests using@iso:on each FROM clause for time-consistent pagination.
The iso field is always present regardless of how many ledgers are involved.
Rust API
use fluree_db_api::{FormatterConfig, AgentJsonContext};
let config = FormatterConfig::agent_json()
.with_max_bytes(32768)
.with_agent_json_context(AgentJsonContext {
sparql_text: Some(sparql.to_string()),
from_count: 1,
iso_timestamp: Some(chrono::Utc::now().to_rfc3339()),
});
let result = db.query(&fluree)
.sparql("SELECT ?name ?age WHERE { ?s ex:name ?name ; ex:age ?age }")
.format(config)
.execute_formatted()
.await?;
Or on a QueryResult directly:
let json = result.to_agent_json(&snapshot)?; // no budget
let json = result.to_agent_json_with_config(&snapshot, &config)?; // with budget
FormatterConfig::agent_json() is one of four format constructors — the others are jsonld(), sparql_json(), and typed_json(). All work with .format() on db.query(), dataset.query(), and fluree.query_from().
Comparison with other output formats
| JSON-LD | SPARQL JSON | Typed JSON | Agent JSON | |
|---|---|---|---|---|
| Type info | Inferable types bare; non-inferable annotated | Every value wrapped with type/datatype | Every value wrapped with @value/@type | Schema header once; bare values in rows |
| Token cost | Low | High | High | Low |
| Type precision | Partial (strings and numbers implicit) | Full | Full | Full (via schema) |
| Byte budget | No | No | No | Yes |
| Resume pagination | No | No | No | Yes |
| Time-pinning | No | No | No | Yes (@t: in resume) |
| Intended consumer | Applications | SPARQL tooling | Typed struct deserialization | LLMs / agents |