Plank help · updated 2026-09-17
Running commands on the machine
What is actually installed on the workspace machine and what isn't — the interpreter name, where Python packages live, the binaries that are missing, and the scratch paths you can write to.
Agents: fetch the raw markdown of this page at /en/help/running-commands.md
Running commands on the machine
These are the facts about the workspace machine that most often waste a turn. Each one below has been observed failing a real first attempt. Read it before your first bash call in a workspace, and treat it as authoritative over any assumption carried in from another environment.
For what the machine is — where it runs, what persists, how sign-in relays work — see Your assistant's environment.
What's there and what isn't
| Assumption | Reality on this machine |
|---|---|
python | Does not exist. Only python3. Bare python fails with command not found. |
| Python packages are global | Extra packages install to the user site-packages (~/.local/lib/python3.X/site-packages) via pip install --user — that is the home folder, which a shared team chat does not share with a private one. |
PYTHONNOUSERSITE=1 is harmless | It breaks every added package, including all the Google libraries. Never set it. |
file, rg, java, xz are available | None are installed. Use python3 for file inspection, grep (or the grep tool) instead of rg. |
/tmp/opencode is writable | It is root-owned; you run as a normal user. Write scratch files to your own /tmp/<name> or, better, into the workspace. |
Preinstalled and safe to rely on: python3 (with openpyxl, requests in the system path), node, curl, grep, sed, awk, tar, gzip, and the document toolchain below.
A package you pip install --user behaves differently from a preinstalled one. openpyxl and requests ship in the system path and survive anything. Everything you add yourself — the Google client libraries included — lives in the user site-packages and disappears the moment PYTHONNOUSERSITE is set. That difference is invisible until it isn't.
Check your own documents by looking at them
You can render a .pptx, .docx or .xlsx to PDF and then to an image, so look at a document you built instead of assuming it came out right. Text that overflows its box, a title that wrapped to three lines, a chart that covers a label — none of that is visible in the file you wrote, and all of it is visible in the picture.
# 1. convert (the two env settings are required, see below)
SAL_USE_VCLPLUGIN=svp soffice -env:UserInstallation=file:///tmp/lo \
--headless --convert-to pdf deck.pptx --outdir /tmp
# 2. rasterise a page you can actually open and inspect
pdftoppm -png -r 110 /tmp/deck.pdf /tmp/page
- Both env settings are mandatory. Plain
sofficeaborts with "User installation could not be completed";-env:UserInstallationgives it a writable profile andSAL_USE_VCLPLUGIN=svpselects the headless backend. There is no display attached. - Run it once per file, not in parallel. Two
sofficeprocesses sharing a profile directory will fight; give each its own-env:UserInstallationpath if you must overlap. - Also available:
pdftotext(check the text really is in the file),markitdown(dump a built document back to markdown — useful for catching placeholder text you forgot to replace),pandoc,wkhtmltopdf.
To render HTML, use Chromium, not wkhtmltopdf. wkhtmltopdf is a fork of WebKit from 2012: flexbox and grid are partial and web fonts are unreliable, so a page that looks right in a browser can come out broken. chrome-headless-shell is installed and prints at the page size the document's own @page rule asks for:
chrome-headless-shell --no-sandbox --disable-gpu --no-pdf-header-footer \
--user-data-dir=/tmp/chrome-$$ --print-to-pdf=/tmp/out.pdf /abs/path/page.html
--no-sandboxis required — the container does not grant the user namespaces Chromium's own sandbox needs, and without it the browser exits before loading anything.- Give every run its own
--user-data-dir. Two Chromiums sharing a profile lock it, and the second one writes nothing. - Page size comes from the document, not the command. There is no flag for it: put
@page { size: 1280px 720px; margin: 0 }in the CSS or you get US Letter. - Never use
repeating-linear-gradient,repeating-radial-gradientorconic-gradientin a page you are going to print. A PDF gradient is a colour ramp along a line or a circle, solinear-gradientandradial-gradienttranslate directly and cost nothing. These three do not translate, so Chromium falls back to a shading whose colour comes from a PostScript program the reader has to run once per pixel — and Preview on macOS and iOS rasterises the whole element box before it clips, so the price is set by the size of the box the gradient sits on and not by how much of it you can see. A measured twelve-slide deck took 43 seconds to open, 26 of them on the title slide, and 27 of those 43 seconds went on one gradient that was clipped away to nothing and drew zero pixels. The page shows blank until it finishes, so it reads as a corrupt file rather than a slow one. For a hairline grid or a scanline overlay use a repeating SVGbackground-image(adata:URI holding one tile) — it stays vector and renders instantly; for a sweep uselinear-gradientat an angle. - Check the PDF you just printed, because nothing about this is visible in the file you wrote:
python3 -c "import re,sys;print(len(re.findall(rb'/ShadingType\s+1\b',open(sys.argv[1],'rb').read())))" out.pdf. Anything above0means the page will open slowly.plank_deck_qa.pyruns this check for you and fails the deck on it.
Fonts, if the document has Russian or Kazakh text. Use Arial, Calibri, Times New Roman, Courier New or Inter — each has a substitute installed with full Cyrillic coverage, including Әә Ғғ Ққ Ңң Өө Ұұ Үү Һһ Іі. Avoid Cambria: it has no substitute here, so it silently falls back to an unrelated face and the layout you see is not the layout the user gets. If text renders as boxes or the spacing looks wrong in your rasterised page, change the font rather than shipping it.
Node packages for building documents are already global. pptxgenjs, sharp, react, react-dom and react-icons are installed image-wide and NODE_PATH is set so require('pptxgenjs') works from anywhere. Don't npm install them into a workspace — it is slow, it writes thousands of files onto network storage, and it is not needed.
Two habits that save the most turns
- Suppress noise at the narrowest scope. Google's libraries print a
FutureWarningabout the Python version on every run. Silence it withPYTHONWARNINGS=ignore— never by reaching forPYTHONNOUSERSITE, which silences the warning by removing the library. - Write a script file instead of a long
python3 -cone-liner. Inline one-liners with nested quotes are the second most common failure here — the shell mangles them intoSyntaxErroror splits them into stray commands. A file is also re-runnable, and if it carries a@plank-integrationheader it shows up in the user's sidebar. See Workspace scripts & the sidebar.
The shell is non-interactive — a command that waits for input hangs the turn
There is no terminal attached and no one to answer a prompt. A command that stops to ask for something — a login code, a credential, a [y/N] confirmation, a pager waiting for a keypress — never gets an answer; it blocks until the turn is force-ended (~30 minutes later), which the user sees as a failed turn. Treat any interactive command as a turn-killer.
- Never run an interactive login flow —
gcloud auth login,az login, a database client that prompts for a password. For OAuth, use the sign-in relay: generate the URL, let the user approve it in their own browser, and finish in the next turn once they paste the redirect URL back. Never keep a process alive waiting for them. - The common offenders are pre-configured to fail fast, not hang. The image sets
CLOUDSDK_CORE_DISABLE_PROMPTS=1(gcloud won't prompt),GIT_TERMINAL_PROMPT=0(git won't ask for credentials) andPAGER=cat(no pager). When one of these errors instead of hanging, read the error and don't re-run the same interactive command. Don't rely on this catching every tool, though — for anything else, still reach for the explicit non-interactive flag below. - For anything else, force non-interactive mode yourself: pass the tool's flag (
-y,--yes,--no-input,--non-interactive) or redirect input from nothing with< /dev/null. If a task genuinely can't be done without interactive input, say so plainly rather than hanging the turn.
Launching a long job in the background
A job that runs for minutes — a sync, a bulk import, a long render — belongs in the background, so the turn stays free to answer the user while it works. Two things make that actually happen, and each one has been measured failing on its own.
1. The launch is a bash call of its own. Nothing before it on the line: no cd … &&, no … ;, no other command. A && B & does not background B — it backgrounds the whole list, in a subshell that inherits the tool's output pipe, and the call does not return until that pipe closes. Measured here on a 6-second job: the shell exits after 0.012s and the pipe closes after 6.02s, so the call takes the full six seconds. In production a background sync launched behind a compound expression held one bash call for 616.327s; the identical launch as a standalone call returns in 0.284s. Use absolute paths, or the shell tool's workdir parameter — not cd. The tool resolves its working directory per call, and its own instructions say to avoid cd <directory> && <command> and pass workdir instead, so a cd you ran in an earlier call is not carried into this one. (A ; before the launch does not fork that subshell and does return at once — the rule bans it anyway, because the alternative is re-deriving bash's &/&& precedence at the moment you are typing a launch, and one shape that is always right costs nothing to remember. The ban on && and ; is about the launch: ordinary commands that background nothing can still be chained however you like. The cd rule is not — it holds for every call, because no call inherits the previous one's directory.)
2. Both output streams go to a log file. > job.log 2>&1. A job that still has the tool's pipe as its stdout holds the call open in exactly the same way, & or no & — measured, an unredirected nohup sleep 6 & takes 6.0s to return.
mkdir -p /workspace/logs # its own call: nothing may precede the launch
nohup python3 -u /workspace/scripts/sync.py > /workspace/logs/sync.log 2>&1 &
echo "started pid $!"
/workspace/logs/ does not exist in a fresh workspace — create it in a call of its own, before the launch. When the redirect's directory is missing, the launch writes nothing anywhere, and you are polling a log that will never appear.
-u is load-bearing, not a flourish. Python's stdout is block-buffered when it points at a file, so a job that prints a progress line every 15 seconds writes nothing at all into the log until it exits or fills the ~8 KB buffer. Measured here, three seconds into a job printing once a second: the log is empty without -u, and has three lines with it. An empty log is indistinguishable from a job that never started — it is what five of six log reads found during the production incident. For something that is not Python, use stdbuf -o0 -e0 <command> instead — that unbuffers both streams. Not -oL or -eL: line buffering flushes on a newline, and a command that reports progress as \rprogress 42 never writes one, so with either of those its output sits in the buffer exactly as if you had not used stdbuf at all — measured 0 bytes in 3 seconds for both, against 44 with -o0. -o0 costs nothing over -oL: both land around 285 ms for 200 000 lines, and the run-to-run spread is wider than the gap between them.
A background job reports progress at least every 15 seconds
The user asks "is it working?" long before the job ends, and you answer them out of the log. So the log has to say something within 15 seconds of the launch, and again at least every 15 seconds after that. A job that prints only when it finishes is invisible: in the production incident that meant 319 seconds of blind polling, five of six log reads empty, and three "is it still running?" messages from the user that nothing could be answered from.
- A script you wrote: print a timestamped line at each step boundary and at least every 15 seconds — with
-u, or it never leaves the buffer. This is the case you control, and it is the one that matters: make the job talk. - A command you cannot change: you cannot make it talk, so do not pretend to. Two things you can observe are true without any wrapper around it:
- the log's size and modification time. Growth between two polls is movement; no growth is not proof of a wedge, but it is what you report.
- whether the process you started is still alive. The launch printed its pid, so
kill -0 <pid>answers that in one command.
kill -0 12345 2>/dev/null && echo "still running" || echo "the process I launched has exited"
ls -l --time-style=+%H:%M:%S /workspace/logs/sync.log
- Report what you observed, never an outcome you cannot see. "The process is still running and the log grew by 4 KB in the last minute" is honest. "The sync finished" is a claim you can only make when the job itself said so in the log. A command that detaches into its own session exits from your shell's point of view while its work continues, so a pid that has gone means the process I launched exited — not the job is done.
- Then poll — don't guess.
plank_waitfor 15-30 seconds, thentail -n 20 /workspace/logs/sync.log, and tell the user what it actually said.plank_waitends the moment they write in the chat, so a nudge reaches you in seconds. - If the log never appears at all, the launch did not start: re-read the output of the launch command itself, where the shell reports a failed redirect (a missing
/workspace/logs, for instance) straight back to you. - What this does not do: it does not make the job finish sooner. A connector's own backend sync was measured at 463-958 seconds and still takes exactly that long. What changes is that you can answer while it runs, and the user can see it moving.
Credentials never go into the output of a command
A transcript is permanent. A credential printed into one cannot be taken back out — deleting the file it came from changes nothing, and some keys cannot even rotate themselves. So the rule is not "be careful with secrets", it is never let one become output.
- Never trace a command that reads a credential file.
bash -x,set -xandsh -xecho every assignment they execute, so tracing a script that doessource .envprints the whole file. Nothing in the shell stops you: the machine deliberately does not wrapsource, because doing so changes how every other sourced file behaves. This one is on you. - Read one value, not the file.
grep -m1 '^POSTHOG_PERSONAL_API_KEY=' .env | cut -d= -f2-into a variable, and use the variable. Nevercat,echo,headorprintfa credential file, and never echo a variable holding a key to check whether it is set — test it with[ -n "$VAR" ]instead. - Keep keys out of the command line too.
curl -H "Authorization: Bearer $TOKEN"is fine; pasting the literal token there is not, because the command is shown alongside its output. - If something does slip through, say so in the same turn. Plank blanks credential-shaped values it recognises before they are stored, and you'll see
[plank:secret-redacted]where the value was. That is a backstop, not a licence: tell the user which credential was exposed so they can rotate it, because a key that is still valid is still a leak.
When something is genuinely missing
Install it with pip install --user <package> for Python, or npm install --prefix scripts/<provider> <package> for Node, so the package lives next to the script that needs it. Never install into .opencode/node_modules — it is the image's plugin tree and is reset from the image. A pip install --user package lives in the home folder, which a shared team chat does not share with a private one, so a script both kinds of chat run may need it installed again. Don't reach for apt-get — you are not root, and a system package would not survive the machine being recycled anyway. If a task truly needs a binary that isn't there, say so plainly rather than burning turns on workarounds.