In this post we build something most agent demos skip: an agent that does real work on real data, inside guardrails it canβt escape. The agent turns a messy folder of receipts and invoices into a clean, validated ledger, and it does it on a lakeFS branch mounted as an ordinary filesystem inside an E2B sandbox. The agent writes plain files; underneath, every change is versioned, every run is isolated, and nothing reaches production until it passes a server-side check.
Two pieces make that possible, and they do different jobs:
- E2B gives the agent a secure place to run : an isolated sandbox β a Firecracker microVM with a Linux kernel β that starts in less than 60 milliseconds, gives the agent a full Linux environment: filesystem access, CLI tools, external API calls, and the ability to run AI-generated code
- lakeFS gives the agent a safe thing to run on : the control plane for AI-ready data, powered by data version control. Each run gets its own zero-copy branch; intermediate and failed states never touch main; and a pre-merge check decides what gets promoted.
The one-liner we kept coming back to: E2B is where the agent works; lakeFS is what it works on. Letβs build it.
Why this is hard #
The moment an agent touches data, you inherit two distinct risks, and most setups only handle one of them.
-
Compute risk. Agent-generated code is unpredictable. It can read the wrong thing, write the wrong thing, or reach the network in ways you didnβt intend. You want it to run somewhere isolated . That rules out more than it sounds like. A generic container runtime gives youprocess isolation, but not a real kernel underneath. Thatβs fine if you need to simply execute code, but you need something that behaves like a real machine with Linux, a filesystem, and the ability to mount things.
-
Data risk. Even perfectly behaved code mutates state. Without isolation, every intermediate and failed attempt lands on the same data everyone else depends on, with no record of what changed and no clean way to undo it. You want the data to be versioned and governed .
E2B addresses the first; lakeFS addresses the second. Youβd need both before youβd trust an agent near production data.
What weβre building #
The full flow, end to end:
- A messy inbox/ of receipts lands on a lakeFS repoβs main branch.
- The host creates a fresh lakeFS branch for this run.
- The host starts an E2B sandbox and mounts that branch at /home/user/mnt.
- The agent runs three progressive phases, triage β extract β validate, as plain file I/O on the mount. During validation, the agent may and escalate an ambiguous case to a human instead of guessing, then resume once it gets an answer.
- The host commits to lakeFS after each phase, producing an auditable, per-phase history.
- A server-side pre-merge gate validates the final ledger; only a clean result merges into main.
- Every commit is linked back to the exact E2B sandbox that produced it.
The agent code never imports an S3 client or calls the lakeFS API. It does glob, open, and write. Thatβs the whole point.
Step 1 β A deliberately messy inbox #
We seed main/inbox/ with real-world garbage: receipt photos and scanned invoices across many formats (JPG, PNG, WebP, BMP, TIFF, single- and multi-page PDF), plus exact duplicates, a corrupt file, a non-receipt photo, and an unsupported .txt. This is the βbefore.β Nothing youβd want to run an agent against unguarded.
Step 2 β Start a sandbox and mount the branch #
The host creates the runβs branch in lakeFS, then starts an E2B sandbox and mounts the branch inside it. Credentials reach the sandbox only as environment variables, never on a command line:
from e2b import Sandbox
sbx = Sandbox.create(template="mount-receipts", envs=lakefs_creds, timeout=900)
sbx.commands.run(
f"everest mount lakefs://{repo}/{branch}/ /home/user/mnt "
"--protocol fuse --write-mode"
)
Two things are worth pausing on, one from each side:
- It only works because the sandbox is a real microVM. lakeFS Mount (Everest) uses FUSE, which needs a real kernel and /dev/fuse. An E2B sandbox is a Firecracker microVM, so it has both. A thin container generally wouldnβt.
- The branch is now just a path. From here on, anything that reads or writes /home/user/mnt is reading and writing a versioned lakeFS branch, with zero data-access code.
Step 3 β The agent is just file I/O #
Here is the part that makes this pattern click. Inside the sandbox, the agent globs the inbox, reads each image, calls a vision model, and writes structured output. These are ordinary filesystem operations. It has no idea its filesystem is versioned:
import glob, os
for path in sorted(glob.glob("/home/user/mnt/inbox/*")):
if is_duplicate(sha256_file(path)):
continue
image = load_image_png(path) # plain open() β None if corrupt
if image is None:
continue
record = extract(image, model="gpt-4o") # vision model call
write_json(f"/home/user/mnt/sidecars/{os.path.basename(path)}.json", record)
State flows from one phase to the next through files on the mount, triage.json, ledger_draft.json, ledger.csv, validation/latest_result.json. The same agent code would run unchanged against a local folder. The only difference is that here every write is captured by lakeFS.
Phase 3 is where βexecute whatever code the agent generatesβ gets concrete. Business rules donβt stay fixed, which means the validator function wonβt stay fixed. Policy caps change, a new vendor needs its own currency handling, someone adds a rule for invoice-number formats next quarter. Coding and redeploying a validator for every variant doesnβt scale, but letting the agent generate the check from a spec does. Rather than running a validator we shipped, the agent writes the per-receipt validation logic at runtime from a plain-English rule spec and runs that never-before-seen Python inside the sandbox, exactly what an isolated microVM is for. Because that code is model-written and therefore untrusted, the server-side gate in step 6 re-checks its output independently.
Step 4 β Human-In-The-Loop Escalation #
Not every case is a clean accept/reject. A low-contrast scan of a receipt may be too illegible to trust. Rather than guess or force a default call, the agent uses its own judgement on whether a case is ambiguous.
Two examples it might use:
if not currency:
outcome, reason = "ambiguous", "currency could not be determined"
elif 0.01 < abs(total - line_item_sum) <= max(5.00, 0.05 * total):
outcome, reason = "ambiguous", f"total {total} vs sum(items) {line_item_sum} β possible misread digit"
if validation["status"] == "awaiting_review":
sandbox_id = sbx.beta_()
decision = ask_human(
channel="#receipts-review",
message=f"{row['source_file']}: the agent flagged this ambiguous β {row['reason']}. "
"Approve, reject, or name the actual currency if that's the issue "
"(EUR, GBP, CHF, CAD, or USD).",
)
sbx = Sandbox.connect(sandbox_id) # resumes automatically
write_decision(sbx, row["source_file"], decision)
rerun_phase(sbx, "validate") # the agent applies the decision and commits it
A person answers whenever they get to it on their own time using the /resume feature. When the reply comes back, the agent parses it and resumes where it left off, and that decision gets committed to the branch.
Step 5 β Commit after every phase #
The host drives the three phases and commits the mount after each one. Because each phase is its own commit, the branch ends up with a readable, auditable history of exactly what the agent did and when:
for i, (phase, name) in enumerate(PHASES, start=1):
sbx.commands.run(f"python -m mount_receipts.agent_runner {phase} /home/user/mnt")
sbx.commands.run(f'everest commit /home/user/mnt -m "Phase {i} β {name}"')
Expected output:
Creating branch 'agent-run-20260706-091512' from 'main'...
Sandbox: ivfmkyxxxxxxxxxxxxxxxxv (template=mount-receipts)
Using prebaked template...
Mounting lakefs://iddo-e2b-receipts-ledger-demo/agent-run-20260706-091512 (FUSE, write-mode)...
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Phase 1 β Triage (structural)
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
{"phase": "triage", "inbox": 15, "kept": 11, "dropped": 4}
committed: afa3164d7324
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Phase 2 β Extract (multimodal)
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
{"phase": "extract", "kept": 11, "extracted": 11, "extraction_failed": 0}
committed: 28b779a4ef52
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Phase 3 β Validate (business rules)
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
{"phase": "validate", "status": "awaiting_review", "summary": "2 row(s) flagged ambiguous by the validator β awaiting human review", "pending_review": 2}
committed: 792aa3f535e2
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
2 row(s) flagged ambiguous by the validator β pausing sandbox for human review...
Sandbox d (ivfmkyxxxxxxxxxxxxxxxxv).
[#receipts-review] receipt_nocurrency.jpg: the agent's validator flagged this ambiguous β currency could not be determined. Approve, reject, or name the actual currency if that's the issue (EUR, GBP, CHF, CAD, or USD).
[#receipts-review] receipt_smudged.jpg: the agent's validator flagged this ambiguous β total 16.25 vs sum(items) 15.75 (off by 0.50) β possible misread digit. Approve, reject, or name the actual currency if that's the issue (EUR, GBP, CHF, CAD, or USD).
... 4 minutes later, a reviewer replies in the thread: "EUR" and "approve" ...
Resuming sandbox with human decisions...
{"phase": "validate", "status": "passed", "summary": "6 accepted, 5 rejected, 0 dropped β ledger valid"}
committed: 5c1e9a02d4f1
Run manifest committed (E2B sandbox: https://e2b.dev/dashboard/iddoavneri/sandboxes/ivfmkyxxxxxxxxxxxxxxxxv/monitoring)
Merging 'agent-run-20260706-091512' into 'main'...
Merged. ref: be2f09b7c0e8274592ebb815924bb02f5bf68e430790ec2654f74c452c8804c6
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Final Report
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Branch : agent-run-20260706-091512
Sandbox : ivfmkyxxxxxxxxxxxxxxxxv
E2B link : https://e2b.dev/dashboard/iddoavneri/sandboxes/ivfmkyxxxxxxxxxxxxxxxxv/monitoring
Outcome : PASSED merged=True
Ledger : 6 accepted, 5 rejected, 4 dropped
Reviewed : 2 row(s) resolved by a human (see validation/human_review_log.json)
Summary : 6 accepted, 5 rejected, 4 dropped β ledger valid
lakeFS UI: https://myorg.us-east-1.lakefscloud.io/repositories/iddo-e2b-receipts-ledger-demo/objects?ref=agent-run-20260706-091512
lakeFS commit log on the agent-run branch: triage, extract, validate, human review and the manifest commit, then the merge to main.
Step 6 β A gate the agent cannot talk its way around #
Committing every attempt is only safe because not every attempt is allowed to merge. lakeFS runs a server-side pre-merge hook that re-validates the committed ledger independently. It re-derives accept/reject for every row from the data itself (currency, amount vs. line-item sum, the policy cap, dates, and invoice-number uniqueness) instead of trusting the pass/fail the agent reported. It runs on the server, regardless of who or what triggers the merge. Even a buggy or self-serving validator cannot promote bad data:
name: Ledger Quality Pre-Merge Gate
on:
pre-merge:
branches: [ main ]
hooks:
- id: check_ledger_validation
type: lua
properties:
script: |
local lakefs = require("lakefs")
local json = require("encoding/json")
-- Re-derive the verdict from the committed data, row by row β
-- don't trust the status the (LLM-written) validator reported.
local _, body = lakefs.get_object(
action.repository_id, action.source_ref, "validation/gate_input.json")
local gate = json.unmarshal(body)
for _, row in ipairs(gate["rows"]) do
if row["decided"] == "accepted"
and (row["currency"] ~= "USD" or row["total"] > gate["policy_cap"]) then
error("Pre-merge gate FAILED: " .. row["source_file"]
.. " was accepted but violates policy")
end
end
-- the full hook also re-checks sum-of-line-items, dates, and invoice uniqueness
This is the Write-Audit-Publish pattern, enforced as a Pull Request for data. The agent proposes a change on a branch, the server audits it, and only valid data is published to main.
Step 7 β From any commit back to the sandbox that made it #
Reproducibility is built in, not bolted on. When the host commits, it writes the sandboxβs URL into lakeFS commit metadata using lakeFSβs clickable-link convention, so you can jump from a commit straight to the E2B sandbox that produced it:
meta["::lakefs::E2B Sandbox::url[url:ui]"] = (
f"https://e2b.dev/dashboard/{team}/sandboxes/{sbx.sandbox_id}/monitoring"
)
What makes this practical at scale #
Hereβs why this approach works when you apply it to real-world datasets. These advantages come from the combination, not from either product on its own.
| Property | What We Saw | Why It Matters |
|---|---|---|
| Fast starts | Baking everest + FUSE + deps into a custom E2B template dropped a ready-to-mount sandbox from ~19s to ~1s (β3s including the fresh per-run agent-code upload) |
You can spin up a fresh, isolated environment per run without paying a setup tax each time |
| Lazy fetch | A mounted branch of 4.88 GB / 5,000 files fetched only ~9 MB β the files the agent actually opened (just ~6 MB to mount and list all 5,000) | Bring a huge dataset; the agent pays only for the sliver it touches, and the mount is instant regardless of size |
| Survives /resume | After a 30-minute + resume, the mount was intact: cached re-reads, fresh reads from the object store, and everest commit all worked |
E2B sandboxes to save cost; the mount reconnects transparently, with no hang or manual remount |
| Live filesystem view | The E2B dashboard Filesystem tab shows /home/user/mnt as the agent reads and writes | A real-time window into what the agent is doing to the versioned data |
A note on the prebaked template: the template pre-installs Everest, FUSE, and other system dependencies. This ensures the sandbox starts fast, but the agent code is always uploaded fresh so it never runs an outdated version.
We specifically tested /resume behavior, since many FUSE/NFS clients drop their connection across a and never recover. We tested it directly: mount a branch, read a file, the sandbox for 30 minutes (well past typical idle-connection timeouts, so the connection really does have to be re-established), then resume. Afterward the mount was still there. A previously-read file read back from cache, a brand-new file read from the object store, and a write plus everest commit all suceeded. Everest reconnected transparently, no hang, and no manual remount.
Conclusion #
Agents are only as trustworthy as the environment you put them in. E2B gives them a secure, fast, real-Linux place to execute generated code. lakeFS gives them a versioned, branch-isolated, governed view of data they can treat as an ordinary filesystem, with a server-side gate that decides whatβs allowed to reach production. The receipts pipeline is a small example, but the shapeΒ (isolated compute on E2B, versioned and gated data on lakeFS, joined by a mount) is exactly what βmessy input in, trustworthy output outβ agents need.
Try it yourself #
The full demo is open and runnable: https://github.com/treeverse/lakeFS-samples/tree/main/01_standalone_examples/e2b/usecases/03-mount-receipts
Youβll need:
- An E2B account β sign up at e2b.dev and grab your key from thedashboard .
- A lakeFS instance with Mount enabled β lakeFS Cloud is the fastest path.
Key links
If youβre building agents that touch real data, weβd love your feedback, especially on the seam where compute isolation and data governance meet.