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 you process 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 pause 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
# Start an isolated E2B sandbox (a Firecracker microVM).
# Using a prebaked template that already contains everest + FUSE + deps.
sbx = Sandbox.create(template="mount-receipts", envs=lakefs_creds, timeout=900)
# Mount the lakeFS branch as a normal filesystem inside the sandbox.
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
# Phase 1 (triage): dedupe by hash, drop corrupt files and non-receipts.
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.
# Inside the sandbox: the agent's own generated validator makes the ambiguity call itself.
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"
# On the host, once Phase 3 reports any ambiguous rows — trusted as-is, whatever reason
# the agent gave.
if validation["status"] == "awaiting_review":
sandbox_id = sbx.beta_pause()
# sandbox stays paused — no cost, no idle compute — until a human replies
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 pause/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 paused (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-091512lakeFS 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:
# lakefs_actions/validate_ledger.yaml
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 uniquenessThis 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 | 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 pause/resume | After a 30-minute pause + resume, the mount was intact: cached re-reads, fresh reads from the object store, and | E2B sandboxes pause 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 pause/resume behavior, since many FUSE/NFS clients drop their connection across a pause and never recover. We tested it directly: mount a branch, read a file, pause 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 the dashboard.
- 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.



