dd-cli is in beta. Access is waitlist gated, and its commands can change between releases.
doordash-oss/doordash-clidd-cli is DoorDash’s command-line client — it finds restaurants, builds carts, and places real orders that arrive at a real door. Bake it into a VM snapshot, mint an access token on your own machine, then bake that into a second snapshot so every VM you boot is already signed in. This is one of the few sandbox workloads that spends money, so the last section is a one-way door.
Requirements
Before you start, make sure you have:
- A Freestyle API key — to create the VM and its snapshots.
- A DoorDash account with CLI access approved. dd-cli is waitlist-gated — join the waitlist with the same account you’ll order from. Access turns on once you’re off the waitlist and have signed in with
dd-cli login; there is no separate switch to flip in account settings. Ordering also needs a saved delivery address and payment method on that account. - dd-cli v0.2.2 or newer on your own machine — you mint the sandbox’s token there. v0.2.2 is the first release that ships a Linux build and the
export-tokencommand; on anything earlier neither exists.
Install the SDK
pnpm add freestyle@latestbun add freestyle@latestnpm install freestyle@latestyarn add freestyle@latest Set your API key before calling the API:
export FREESTYLE_API_KEY="your-api-key"
Build a Snapshot with the DoorDash CLI Installed
dd-cli ships one binary per platform. Freestyle VMs are x86_64 Linux, so the VM needs the linux-amd64 release — the darwin-arm64 tarball you may already have installed locally is a Mach-O binary and will not execute there. Download it, check it against the published SHA256, and put the binary on PATH.
Every release asset has a .sha256 sidecar published beside it, so the checksum below isn’t something you have to compute — it’s the contents of dd-cli-v0.2.2-linux-amd64.tar.gz.sha256, which pins dd-cli-v0.2.2-linux-amd64.tar.gz from the v0.2.2 release. The value is specific to that one file: bump ddVersion and the checksum must be replaced with the new release’s, or sha256sum -c fails on a download that was perfectly fine. It also differs from the darwin-arm64 asset’s checksum in the same release.
Skip the bundled install.sh. It installs to $HOME/.local/bin, and the bare exec shell has no HOME; it also stops to ask which AI agent you use, which a scripted provision can’t answer. Installing straight to /usr/local/bin sidesteps both.
import { Freestyle } from "freestyle";
const freestyle = new Freestyle();
const { vm: builder } = await freestyle.vms.create({
// Required: a VM reaches nothing it has not been allowed to.
firewall: { rules: [{ action: "allow", source: {}, destination: { public: true } }] }, slug: "doordash-builder" });
const ddVersion = "0.2.2";
const ddDir = `dd-cli-v${ddVersion}-linux-amd64`; // the tarball nests the binary under this name
const ddBase = "https://github.com/doordash-oss/doordash-cli/releases/download";
const ddUrl = `${ddBase}/v${ddVersion}/${ddDir}.tar.gz`;
// Contents of the .sha256 sidecar published next to the tarball (same URL plus
// a ".sha256" suffix). Tied to this exact asset — re-read the sidecar whenever
// you bump the version, or the check below fails on a good download.
const ddSha256 = "1be803988e41a3f4f093df80e0ea5065940dda8343c565284e26b1d5a8fd289c";
const install = await builder.exec({
command: `set -e
curl -fsSL -o /tmp/dd-cli.tgz "${ddUrl}"
echo "${ddSha256} /tmp/dd-cli.tgz" | sha256sum -c -
tar -xzf /tmp/dd-cli.tgz -C /tmp
install -m 755 /tmp/${ddDir}/${ddDir} /usr/local/bin/dd-cli
rm -rf /tmp/dd-cli.tgz /tmp/${ddDir}`,
timeoutMs: 180_000,
});
console.log(install.stdout?.trim()); // /tmp/dd-cli.tgz: OK
const version = await builder.exec("dd-cli --version");
console.log(version.stdout?.trim()); // dd-cli, version 0.2.2
const { snapshotId } = await builder.snapshot();
await builder.delete();
dd-cli is a PyInstaller onefile build, so every invocation unpacks its ~15 MB payload into $TMPDIR, runs, and deletes it again. There is no warm cache and a snapshot can’t preload one, so every command pays that startup cost — give each call a generous timeoutMs rather than assuming a fast native binary.
Mint an Access Token
dd-cli stores credentials in the operating system keychain, and dd-cli login finishes its sign-in by redirecting to localhost. A headless VM has neither a keychain nor a browser, and there is no device-code or paste-the-URL flag — so don’t try to log in inside the sandbox. It fails immediately, and the error names the fix:
Error: Keychain unavailable. dd-cli requires keychain access to securely store
credentials, or set the DD_CLI_ACCESS_TOKEN env var to run in a headless environment.
DD_CLI_ACCESS_TOKEN takes precedence over the keychain, and dd-cli export-token mints one: it runs the same browser sign-in as login but prints the token instead of saving it. Run this on your own machine, not in the VM:
export DD_CLI_ACCESS_TOKEN="$(dd-cli export-token)"
The token is printed to stdout on its own line and every other message goes to stderr, so the command substitution above captures the token cleanly. Treat it like a password — anyone holding it can order on your account until it expires.
One consequence worth knowing before the next section: without a keychain or that variable, dd-cli refuses to run at all — even dd-cli search --help errors. Only --version and the top-level --help work unauthenticated.
Bake the Token into a Second Snapshot
Boot a VM from the CLI snapshot, write the token to a root-only environment file, and snapshot again. The result is an image whose VMs are signed in the moment they boot.
Put the search coordinates in the same file. search takes --lat / --lng, falls back to DD_LAT / DD_LNG, and with none of them set it quietly defaults to Cupertino — a fresh VM knows nothing about where you are. Replace the coordinates below with your own, or you’ll browse a menu from a city you’re not in. They only decide what search returns; the food goes to the account’s saved default address, which is a separate thing — check it with dd-cli address list.
const { vm: authBuilder } = await freestyle.vms.create({
// Required: a VM reaches nothing it has not been allowed to.
firewall: { rules: [{ action: "allow", source: {}, destination: { public: true } }] },
slug: "doordash-auth-builder",
snapshotId,
});
// CHANGE THESE. Downtown San Francisco stands in for your own location —
// leave them as-is and every search below is for restaurants near this spot.
const searchLat = 37.7749;
const searchLng = -122.4194;
await authBuilder.fs.writeTextFile(
"/etc/dd-cli.env",
`export DD_CLI_ACCESS_TOKEN=${process.env.DD_CLI_ACCESS_TOKEN}
export DD_LAT=${searchLat}
export DD_LNG=${searchLng}`,
);
await authBuilder.exec("chmod 600 /etc/dd-cli.env");
// Interactive shells pick it up automatically; `vm.exec` does not (see below).
await authBuilder.fs.writeTextFile(
"/etc/profile.d/dd-cli.sh",
". /etc/dd-cli.env",
);
const { snapshotId: authedSnapshotId } = await authBuilder.snapshot();
await authBuilder.delete();
This bakes a live credential into an image: anyone who can boot that snapshot can order food on your account. That is the tradeoff you accept for VMs that come up ready to order. If you’d rather not take it, skip this section entirely, boot from the plain snapshotId, and pass DD_CLI_ACCESS_TOKEN="..." inline on each command the way the Claude Code guide threads its API key. Either way, re-run export-token and rebuild when the token expires.
vm.exec runs a bare non-login shell, so it never reads /etc/profile.d. Source the file explicitly instead. Every command below goes through this one helper, which also gives each call the timeout the onefile startup needs:
const { vm } = await freestyle.vms.create({
// Required: a VM reaches nothing it has not been allowed to.
firewall: { rules: [{ action: "allow", source: {}, destination: { public: true } }] },
slug: "doordash-sandbox",
snapshotId: authedSnapshotId,
});
const dd = async (args: string) => {
const run = await vm.exec({
command: `. /etc/dd-cli.env && dd-cli ${args}`,
timeoutMs: 120_000,
});
if (run.statusCode !== 0) throw new Error(run.stderr ?? "dd-cli failed");
return run.stdout ?? "";
};
// Single-quote a value for the shell (the CLI takes multi-line arguments).
const sh = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`;
Describe Your Intent
Every command that reaches DoorDash requires --intent, and it is not optional — omit it and the command errors out. It describes why you’re calling, not what the command does, and it takes two lines: a Summary: of who this is for and the goal, then the verbatim prompt that started the session. DoorDash may review it for research and product improvement, so keep personal details out of it.
Build it once and reuse it, since every call below needs it:
const intent = sh(
`Summary: Help the user order lunch to their saved home address
user prompt/purpose: "order me a burrito for lunch"`,
);
Find a Restaurant and Read Its Menu
search finds restaurants near a set of coordinates. Add --json-output to get the raw structured envelope instead of the rendered text — note it is a global flag, so it goes before the subcommand, not after it:
const results = JSON.parse(
await dd(`--json-output search --query ${sh("burrito")} --limit 5 --intent ${intent}`),
);
const storeId = results.stores[0].store_id; // e.g. "928163"
A store’s menu carries the menu_id you need to add anything to a cart, plus the item ids:
const menu = JSON.parse(await dd(`--json-output menu --store-id ${storeId} --intent ${intent}`));
const menuId = menu.menu_id; // e.g. "1657275"
const item = menu.categories[0].items[0]; // pick something real here
For the full description, price, and customization options of a single dish, use restaurant-item-details. It wants the item id without the i_ prefix the menu returns — pass i_23266866023 and it fails, pass 23266866023 and it works:
const itemId = String(item.item_id).replace(/^i_/, "");
const details = JSON.parse(
await dd(
`--json-output restaurant-item-details --store-id ${storeId} ` +
`--menu-id ${menuId} --item-id ${itemId} --intent ${intent}`,
),
);
Build a Cart
Check for an existing cart first. DoorDash allows one open cart per store, and cart add-items without --cart-uuid appends to that open cart rather than starting fresh — so a leftover cart from an earlier run silently joins your order:
const existing = JSON.parse(
await dd(`--json-output cart list --store-id ${storeId} --intent ${intent}`),
);
for (const cart of existing.carts ?? []) {
await dd(`cart delete --cart-uuid ${cart.cart_uuid} --intent ${intent}`);
}
Now add the item. Each entry in --items-json needs item_id, item_name, and quantity; customizations go in a nested_options array:
const items = sh(
JSON.stringify([{ item_id: itemId, item_name: item.name, quantity: 1 }]),
);
const cart = JSON.parse(
await dd(
`--json-output cart add-items --store-id ${storeId} ` +
`--menu-id ${menuId} --items-json ${items} --intent ${intent}`,
),
);
const cartUuid = cart.cart_uuid;
cart add-items is additive: calling it twice with the same item sums the quantities, and there is no set-quantity operation. To correct a line, remove it and add it back — and note that cart remove-item --cart-item-id wants the cart line id from cart show, not the menu item_id.
Preview the Order
order preview prices the cart — subtotal, taxes, fees, delivery time — without charging anything. Read it back with --beautify for a human-readable summary:
console.log(await dd(`order preview --cart-uuid ${cartUuid} --beautify --intent ${intent}`));
// Confirm a card is actually on file. Wallets (Apple Pay, PayPal, Venmo) and
// gift cards do not appear here, so an empty list is not proof of no payment method.
console.log(await dd(`--json-output payment-method list --intent ${intent}`));
--beautify and --json-output are mutually exclusive — pick one per call.
Preview is read-only unless you pass --fulfillment, which flips the cart between delivery and pickup before pricing it. Whatever you pass here — --fulfillment, --priority, --scheduled-time, --no-apply-credits — you must pass identically to order submit, because a quote only holds for the exact configuration it was computed for.
Place the Order
This is the point of no return. order submit charges the account’s default payment method and dispatches a real order to a real address. It is also not idempotent: submitting the same cart_uuid twice creates two orders, and it has a 30-second timeout with no auto-retry. If a call times out, check order history before trying again.
--yes is mandatory here. Without a TTY the confirmation prompt has nothing to read from, so the call hangs until vm.exec times out rather than failing. And --tip-cents is in cents — 500 is five dollars, 5 is a nickel:
const submitted = JSON.parse(
await dd(
`--json-output order submit --cart-uuid ${cartUuid} ` +
`--tip-cents 500 --yes --intent ${intent}`,
),
);
const orderUuid = submitted.order_uuid;
A successful return only means the order was accepted into processing — payment and verification may still be running. Poll order status until it stops being pending; successful is the only value that means the food is coming:
let status = "pending";
while (status === "pending") {
await new Promise((r) => setTimeout(r, 5000));
const res = JSON.parse(
await dd(`--json-output order status --order-uuid ${orderUuid} --intent ${intent}`),
);
status = res.status;
}
console.log(status); // "successful" | "action_required" | "failed"
action_required means somebody has to finish a verification step in the DoorDash app before the order moves. Age-restricted items can’t be checked out from the CLI at all — submit returns error_reason=AGENTIC_RESTRICTED_ITEM_NOT_ALLOWED, and the fallback is order checkout-url --cart-uuid <uuid>, which hands back a browser link for the same cart.
Run it Interactively over the PTY
vm.exec() is request/response — it can’t stream output or take keystrokes. To drive dd-cli by hand, open a PTY: a real pseudo-terminal in the VM, streamed over a WebSocket. The WebSocket carries auth headers, which browsers can’t set, so the PTY is server-side only (Node 22+).
A PTY shell is a login shell, so it sources /etc/profile.d/dd-cli.sh on its own — the token and coordinates are already in the environment:
const session = await vm.pty.open({
cols: 100,
rows: 30,
onData: (bytes) => process.stdout.write(bytes),
});
// TERM isn't set in the PTY shell; set it so the CLI's output renders properly.
session.write(`TERM=xterm-256color dd-cli order history --beautify --intent ${intent}\n`);
// session.detach() drops your handle without killing the session.
This is also the easiest way to interrogate the CLI: dd-cli --help lists the command groups, and each group and leaf takes --help for the level below it.
Hand the CLI to an Agent in the VM
dd-cli is built for agents, and its tarball ships an agent skill describing the whole command tree. The installer only offers to place it when it’s attached to a terminal, so a scripted provision like the one above skips it — copy it in yourself. Drop it in a coding agent’s skills directory and the agent can order food without being told how:
await vm.exec(`set -e
curl -fsSL -o /tmp/dd-cli.tgz "${ddUrl}"
tar -xzf /tmp/dd-cli.tgz -C /tmp
mkdir -p /root/.claude/skills/dd-cli-usage
cp /tmp/${ddDir}/skills/dd-cli-usage/SKILL.md /root/.claude/skills/dd-cli-usage/SKILL.md`);
Pair that with How to Run Claude Code in a Sandbox and the agent in the VM can search, cart, and order on its own. Give that combination the same thought you’d give any agent with a payment method: the token in the snapshot is a live credential, and order submit spends real money.