← Blog
July 7, 2026 · 10 min

A working RAG agent in 50 lines: workspace, vectors, chat

Retrieval-augmented generation has a reputation for being a stack decision: pick a loader, a splitter, an embeddings provider, a vector database, a framework to glue them. On LLM4Agents it is four tool calls behind the endpoint you already pay. This tutorial builds a working RAG agent over your own PDFs in under 50 lines — ingest, chat loop, eval, and a cron deploy.

This is the small companion to the migration runbook. That post mapped an entire OpenAI Agent Builder workload onto the platform; this one is for the operator who wants something self-contained to ship in an afternoon. Everything here runs against tools documented in the API docs and covered family-by-family in the MCP server tour.

What you are building

A chat agent that answers questions from a folder of PDFs — a product handbook, internal policies, whatever corpus you own. Two phases, same as every RAG system since the pattern got its name. The ingest phase uploads each PDF to the per-agent workspace, parses it to text, chunks it, and upserts the chunks into the vector store, which auto-embeds them as 768-dimensional bge vectors. The query phase is a conversation where the model holds exactly one tool — vector_query — and decides for itself when to retrieve.

Four primitives do the work: workspace_upload, pdf_parse, vector_upsert, vector_query. The only piece of RAG machinery you own is the chunking function, and it is two lines.

Setup: three lines

npm install @llm4agents/sdk
export LLM4AGENTS_API_KEY=sk-...   # from your agent registration
mkdir docs                          # drop your PDFs here

You need a registered agent with a funded balance. One detail before you start: pdf_parse is one of the three balance-only tools on the MCP server — it takes no x402 walk-up payment. Ingest requires an account; a wallet alone will not run this pipeline, even though vector_query itself accepts x402.

Ingest: upload, parse, embed, upsert

The MCP server speaks Streamable HTTP in JSON response mode, so a tool call is a plain POST with a JSON-RPC envelope. One eight-line helper covers every tool in the catalog. Here is the full TypeScript program — ingest and chat loop together, 44 lines:

import { readFileSync, readdirSync } from 'node:fs';
import readline from 'node:readline/promises';
import { LLM4AgentsClient } from '@llm4agents/sdk';

const MCP = 'https://mcp.llm4agents.com/mcp';
const KEY = process.env.LLM4AGENTS_API_KEY!;

async function call(name: string, args: object) {
  const res = await fetch(MCP, {
    method: 'POST',
    headers: { 'content-type': 'application/json', authorization: `Bearer ${KEY}` },
    body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call',
                           params: { name, arguments: args } }),
  });
  return (await res.json()).result;
}

const chunk = (t: string, n = 1200) =>
  Array.from({ length: Math.ceil(t.length / n) }, (_, i) => t.slice(i * n, i * n + n));

// ingest: upload -> parse -> chunk -> embed + upsert
for (const f of readdirSync('docs').filter((n) => n.endsWith('.pdf'))) {
  await call('workspace_upload', { filename: f, days_to_store: 90,
    content_base64: readFileSync(`docs/${f}`).toString('base64') });
  const { text } = await call('pdf_parse', { workspace_file: f });
  await call('vector_upsert', { items: chunk(text).map((text, i) =>
    ({ id: `${f}-${i}`, text, metadata: { source: f } })) });
}

// query loop: the model retrieves for itself via the allowlist
const client = new LLM4AgentsClient({ apiKey: KEY });
const conv = client.chat.conversation({
  model: 'anthropic/claude-sonnet-4.6',
  system: 'Answer strictly from the handbook corpus. Always call vector_query ' +
    'before answering. Cite the source filename. If nothing relevant returns, say so.',
  tools: { mcp: { url: MCP, allow: ['vector_query'] } },
  maxToolRounds: 2,
});

const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
while (true) {
  const reply = await conv.say(await rl.question('you> '));
  console.log(reply.content);
}

Three decisions in that file are worth naming. The chunk size of 1,200 characters is a starting point, not doctrine — small enough that top_k: 5 stays within a modest context budget, large enough that a policy paragraph survives intact. The metadata: { source: f } field is what lets the model cite filenames, and it is the same metadata surface that scopes multi-tenant corpora at query time with filters. And there is no embeddings step in the code because there is no embeddings step: vector_upsert embeds server-side as part of the call.

The query loop: retrieval belongs to the model

Notice what the query half does not do. It does not embed the user's question, run a similarity search, stuff chunks into the prompt, and template an answer. It hands the model a single tool and a system prompt that says to use it. The model formulates the search query itself — often better than the user's phrasing — and maxToolRounds: 2 gives it one retrieval and one follow-up if the first pass came back thin.

The allowlist is doing security work, not just tidiness. A corpus built from parsed PDFs is untrusted input, and a model that can see one read-only tool can be prompt-injected into calling one read-only tool. The discipline is the same scope minimization argument from the threat model post: this agent answers questions, so it sees vector_query and nothing else. Ingest runs as a separate process with a separate, wider allowlist.

The same thing in Python

The Python SDK is symmetric, and the whole program lands around 30 lines:

import base64, os, pathlib, requests
from llm4agents import LLM4AgentsClient

MCP = 'https://mcp.llm4agents.com/mcp'
KEY = os.environ['LLM4AGENTS_API_KEY']

def call(name, args):
    r = requests.post(MCP, headers={'Authorization': f'Bearer {KEY}'},
                      json={'jsonrpc': '2.0', 'id': 1, 'method': 'tools/call',
                            'params': {'name': name, 'arguments': args}})
    return r.json()['result']

def chunks(t, n=1200):
    return [t[i:i+n] for i in range(0, len(t), n)]

for f in pathlib.Path('docs').glob('*.pdf'):
    call('workspace_upload', {'filename': f.name, 'days_to_store': 90,
         'content_base64': base64.b64encode(f.read_bytes()).decode()})
    text = call('pdf_parse', {'workspace_file': f.name})['text']
    call('vector_upsert', {'items': [
        {'id': f'{f.name}-{i}', 'text': c, 'metadata': {'source': f.name}}
        for i, c in enumerate(chunks(text))]})

client = LLM4AgentsClient(api_key=KEY)
conv = client.chat.conversation(
    model='anthropic/claude-sonnet-4.6',
    system='Answer strictly from the handbook corpus. Always call vector_query '
           'before answering. Cite the source filename. If nothing relevant returns, say so.',
    tools={'mcp': {'url': MCP, 'allow': ['vector_query']}},
    max_tool_rounds=2)

while True:
    print(conv.say(input('you> ')).content)
What it costs — using the documented anchor prices: uploads hit the 1¢ workspace minimum per file, pdf_parse is 0.5¢ per page, vector_upsert is 0.5¢ per 100 chunks. A 120-page handbook split across three PDFs ingests for roughly 65 cents, almost all of it parsing. After that, each question costs 1¢ of vector_query plus the model tokens for the answer. The X-Cost-Usd-Cents header on every response tells you the exact settled charge; the billing internals post explains where that number comes from.

Eval: three golden queries before you trust it

Do not ship a retrieval agent you have never measured, even a 50-line one. The lightest eval that still catches regressions is a handful of golden queries with expected substrings — the floor of the practice we laid out in the evaluation and observability post:

GOLDEN = [
    ('What is the refund window for annual plans?', '30 days'),
    ('Who approves expenses over the team limit?',   'finance'),
    ('What is the SLA for priority-one incidents?',  '4 hours'),
]
for q, must in GOLDEN:
    a = conv.say(q).content
    print('PASS' if must.lower() in a.lower() else f'FAIL {q!r}: {a[:80]}')

Adapt the questions and expected fragments to your corpus. Three queries sounds thin; it is enough to catch the failure modes that actually happen — an ingest run that silently parsed zero pages, a chunk size change that split every policy mid-sentence, a system prompt edit that stopped the model from retrieving at all. Run it after every re-ingest. Total cost per run: about 3¢ plus tokens.

Deploy: Workers Cron until Agent Cron ships

The chat loop can live anywhere — a terminal, a Slack bot, fifty lines of widget glue. The part that needs a schedule is re-ingest, because corpora drift. Agent Cron and the schedule_task tool are still in development, as noted in the MCP tour, so the working substitute is a Cloudflare Workers Cron Trigger: put the ingest half in a Worker's scheduled handler and map a cron expression to it in the config.

# wrangler.toml
[triggers]
crons = ["0 6 * * 1"]   # re-ingest every Monday, 06:00 UTC
export default {
  async scheduled(controller, env, ctx) {
    await ingest(env.LLM4AGENTS_API_KEY);  // the loop from above, minus node:fs
  },
};

Inside a Worker you read source files from R2 or fetch them from URLs instead of node:fspdf_parse accepts a URL as readily as a workspace file. Re-upserting an unchanged chunk under the same id is an overwrite, so the lazy strategy of re-ingesting everything weekly costs you cents, not correctness. When schedule_task ships, this Worker deletes cleanly: the schedule moves inside the platform and onto the same meter as everything else.

How this compares to a LangChain RAG

The honest comparison is components, not lines. The current LangChain RAG tutorial starts at pip install langchain langchain-text-splitters bs4 requests and then has you assemble five pieces: a document loader, a text splitter, an embeddings model, a vector store, and an LLM — with the embeddings provider and the vector store each being a separate vendor decision, a separate credential, and usually a separate bill. None of that is wrong. LangChain's assembly surface is the point: swap Chroma for Pinecone, swap embedding models, insert a reranker, and the framework absorbs the change.

The trade this tutorial makes is the opposite one. Storage, parsing, embedding, retrieval, and model access are one endpoint, one credential, one balance, one cost header — and the only component you own is a two-line chunker. You give up the ability to choose your embedding model; you gain a pipeline whose entire vendor surface is the platform you were already paying for inference, priced per call in sub-cent units. For a handbook-sized corpus and an agent that has to account for its own spending, that is usually the right side of the trade. For a retrieval system with hard requirements on embedding quality or index tuning, it is not, and you should know that going in.

What it means for LLM4Agents

RAG is the workload that proves the composition argument. A gateway that only sold tokens would watch this entire pipeline — storage, parsing, embedding, retrieval — leak to three external vendors, taking the per-task cost accounting with it. Because the four primitives sit on the same meter as the conversation, the operator's question "what does answering one support query cost, all-in" has a single answer in a single currency. That number is the unit economics of an agent business, and keeping it computable inside one billing perimeter is precisely what the platform is for.

The 50-line figure is itself the product claim. The distance between "registered an agent" and "chatting with my own documents" is one afternoon and less than a dollar, and every step of it is legible: no framework internals, no hidden retrieval prompt, just tool calls you can price individually. That legibility is what makes the platform teachable — and what this series keeps cashing in on.

Staying on the frontier

What this tutorial exposes about the platform's gaps, in the order they should close:

  1. Ship schedule_task. The Workers Cron detour is the only step in this post that leaves the platform. Scheduled re-ingest is the most common recurring agent job there is; it should be a tool call on the same meter.
  2. Add a rag_ingest convenience tool. Upload, parse, chunk, and upsert as one server-side call with a documented default chunking policy. The four-primitive decomposition should remain for people who want control; the one-call path is what tutorials and walk-up agents actually need.
  3. Expose embedding model choice on vector_upsert. The single fixed bge model is the comparison's honest weakness. Even two options — the current default plus a multilingual model — would close most of the gap against assemble-your-own stacks.
  4. Publish golden-eval scaffolding. A documented pattern (or a tool) for storing golden queries in memory_set and running them post-ingest would make the eval habit the default instead of a best practice readers skip. It also sets up the promised Promptfoo walkthrough.
  5. Document vector_upsert batch limits. This post upserts a whole handbook in one call because the docs' own example does. The actual per-call item ceiling should be a published number, not folklore.

Your documents, answering questions by tonight

Register an agent, fund it, drop PDFs in a folder, and run the 44 lines above.

Register your agent