Running Octave and other applications in a ChatGPT container
Running Octave and other applications in a ChatGPT container
Duncan Carlsmith
Department of Physics, University of Wisconsin-Madison
Introduction
An AI assistant that can run code can write a script, run it, read the results, and fix errors before reporting back.
Prototyping MATLAB code in a Claude container with Octave describes one way I can supply Claude basic autonomous MATLAB executability by installing Octave in the Claude container instead of using an MCP connection or an HTTPS command server to hand the assistant my own Mac, with its MATLAB, the toolboxes, and the real files. This Octave hack is useful if, for example, I am on my phone while my laptop is offline or otherwise occupied.
ChatGPT runs code in a Linux container with Python, Node, and a shell. GNU Octave is not present and is not straightforwardly installed by the chat itself - an apt-get install octave fails because the container reaches selected package proxies and not the Debian repositories. This post describes a solution provided by Claude for ChatGPT, a solution provided that applies to other packages as well.
The trick is to prepare externally and upload an Octave (or whatever) package and simply drop it into the ChatGPT chat as needed with an installation instruction prompt. Once the chat accomplishes the installation, that chat is good to go. A 197 MB file uploaded to the conversation installs Octave 10.3.0 in about ten seconds. The install needs no root access, no apt, and no network connection from the container. I can then hand ChatGPT an .m file, read the numbers it prints, and retrieve the figure. The same method carries other programs built by conda-forge for Linux x86_64. The ExifTool bundle is 37 MB. The Pandoc bundle is 40 MB.
Be aware that in a ChatGPT Project environment, the uploaded package is available to all project chats but must be installed in each chat’s container that needs it, and can sometimes need a reinstall if you leave a chat in which it is installed. That installation action might be automated via persistent context -I’ve not tried that. Neither have I tried this trick with other AIs.
The rest of this submission is Claude-generated goop describing exactly how to implement this and the tests performed. It is not intended for normal sentient being consumption. Just hand it to your AI.
Background
The Claude container I used to prepare this post runs Ubuntu 24.04 with glibc 2.39, as root, with HTTPS access to an allowed set of hosts. apt-get update reaches the Ubuntu archives, pip reaches PyPI, and apt-get install octave would install Octave 8.4.0 from the distribution. That container also carries ffmpeg and ImageMagick and no Octave.
The ChatGPT container I tested runs Debian 13 with glibc 2.41, unprivileged, with a packages-only network policy. Its pip reaches an internal proxy and apt reaches nothing. Published reconnaissance reports the same policy (Willison, January 2026, container environment notes, sandbox exploration).
So the bundle is a requirement on ChatGPT and an upgrade on Claude. Ubuntu supplies Octave 8.4, the version the companion post worked around. The bundle supplies 10.3.0 on either, because it does not depend on what the distribution packages.
Those reports, and my own measurements, describe particular images in September 2026. The images change. The bundle therefore targets an application binary interface rather than a named vendor image. Conda-forge packages for linux-64 declare a minimum glibc version and an instruction-set baseline, and a Linux host new enough to meet both runs the same binaries. After the design was fixed, ldd --version in the ChatGPT container reported Debian GLIBC 2.41, above the glibc 2.28 floor the bundle assumes.
The method
Micromamba, a single statically linked executable, installs an explicit list of conda packages from local archive files. The bundle contains those files, a Linux micromamba binary and a short installer.
- On your own machine, solve the dependency graph for Linux x86_64 without installing it.
- Download the package archives returned by the solve.
- Put the archives, micromamba and the installer in one tar file.
- Upload the tar, extract it in the container and install under /mnt/data.
Step 4 needs no network, no root access and no system package manager. Everything is written below one directory the container already permits.
Building the Octave bundle
DROP='(qt-|qt6|pyqt-|qscintilla2|libllvm|libclang|ghostscript|mesa)' \
sh build_bundle.sh octave octave
The complete solve contains 181 packages, 460 MB of archives, and installs to 1.8 GB, much of it supporting a graphical interface the container cannot display. Dropping Qt, PyQt, QScintilla, LLVM, Clang, Mesa and Ghostscript leaves 171 packages, a 197 MB tar file and a 773 MB installed tree. Appendix A passes that exclusion list through DROP.
There is a limit to the pruning. octave-cli links libOpenGL.so.0, so libglvnd must remain even though no window is drawn.
Solving on macOS for a Linux target
My first solve on an Apple silicon Mac returned Octave 7.1.0, Python 3.9, Qt 5.12 and a 323 MB tar. The same solve run on Linux returned Octave 10.3.0, Python 3.14 and a 197 MB tar.
The difference comes from conda virtual packages, one of which is __glibc. A Mac has no glibc, so builds requiring __glibc >=2.17 appear unsatisfiable, and the solver walks backward to builds from 2022 and earlier, before that declaration was used. Setting CONDA_OVERRIDE_GLIBC=2.12 on a Linux machine reproduced the Mac result package for package.
Three settings remove the host from the decision.
export CONDA_OVERRIDE_GLIBC=2.28 # describe the target, not this Mac
export CONDA_OVERRIDE_ARCHSPEC=x86_64 # portable baseline, not the host CPU
# on the micromamba command line:
--no-rc --no-env --override-channels # ignore local conda configuration
CONDA_OVERRIDE_ARCHSPEC matters on a fast host. A solve on a machine with AVX-512 selected a metapackage pinned to x86_64_v4, which would fail in a container whose processor lacks those instructions.
Appendix A also rejects a solved Octave version below 10, which catches the silent fallback before hundreds of megabytes are downloaded.
An earlier bug in the build script was less obvious. Its URL filter matched only the linux-64 subdirectory and silently dropped 15 noarch packages. Octave still ran, and figure text fell back to whatever fonts the container had, because the DejaVu fonts are noarch.
Verification
Appendix C contains 13 checks. Each compares an Octave result with a value obtained independently. A truncated package set or a broken BLAS can start normally and return a plausible wrong answer.
The checks cover dense and sparse linear algebra, an ODE, quadrature, an FFT, root finding, minimization, MAT-file and text I/O, basic data structures and a PNG figure. The eigenvalues of the 3 by 3 second-difference matrix are compared with 2 - 2cos(k pi/4), and the ode45 value at t = 5 with the closed-form solution of y' = -2y + sin(t).
All 13 pass in the ChatGPT container. The 400 by 400 dense solve gives a residual of 1.3e-15 in 17 ms. The BLAS is real.

Figure 1. Output of the self-test under Octave 10.3.0 in a ChatGPT container. Blue solid curve, sin(x). Red dashed curve, sin(x)exp(-x/6). Black circles, the ode45 solution of y' = -2y + sin(t), sampled every 25th step.
What is missing
Octave itself does not provide MATLAB's table or string class. Octave Forge packages are unavailable because pkg install tries to download them.
The Qt graphics toolkit needs a display, so the self-test uses gnuplot and print -dpngcairo to write a PNG. Pruning Ghostscript removes EPS and PDF output and the plain -dpng device, and putting it back costs about 60 MB.
Three warnings accompany every figure. GraphicsMagick reports its 8-bit limit, Ghostscript is absent, and fontconfig prints an initialization notice. None changed the PNG output in these tests.
What persisted in the tests
I measured three behaviors in one ChatGPT project in September 2026.
Within one conversation, the container persisted across turns. An Octave install made at the start remained usable for the rest of that chat.
After several hours away, the installed directory was gone and the files uploaded or produced in the conversation had returned, with fresh timestamps. Reinstallation took one instruction and about ten seconds, with no new upload.
$ ls -la /mnt/data/
-rw-r--r-- 1 root oai_shared 206766592 Sep 13 14:40 octave-bundle-linux64.tar
-rw-r--r-- 1 root oai_shared 34124 Sep 13 14:52 octave_selftest.png
A new conversation in the same project started with an empty /mnt/data. Project files were available to ChatGPT for retrieval and did not appear in that conversation's container filesystem, so files belonged to the conversation rather than to the project.
I therefore keep one conversation as the Octave conversation and return to it whenever an .m file needs a trial run.
Other programs
ExifTool resolves to six packages and a 37 MB upload including micromamba. Pandoc resolves to one 23 MB package and a 40 MB bundle. Both use the same two scripts.
sh build_bundle.sh exiftool exiftool
sh build_bundle.sh pandoc pandoc
The Pandoc bundle converted the Markdown source of this post to RTF.
Try pip first. The Python package proxy worked here, and a bundle is unnecessary when pip supplies the program. The bundle earns its keep for compiled programs with shared-library dependencies, where apt would ordinarily be the answer.
The method does not solve a large-data problem. At the time of these tests, an upload was limited to 512 MB, so a program needing gigabytes of auxiliary data, such as the index files of a blind astrometric solver, does not fit merely because its executable does.
Security
The installer runs package scripts supplied by conda-forge, the same trust decision as creating an ordinary conda environment. The container is disposable and had no general outbound network access in these tests, which limits what a malicious package could reach. It does not make an untrusted package safe.
Wrap up
One upload and about ten seconds of installation gave a ChatGPT conversation a working Octave, and the same pair of scripts carried ExifTool and Pandoc. Octave is still not MATLAB. Parser differences, unsupported classes and toolbox calls remain, so the finished program needs a run in MATLAB.
For anyone already running a command server on a personal machine, a custom GPT action can call an HTTPS endpoint that runs MATLAB itself, on hardware that remembers yesterday's installation. The uploaded bundle is for the case in between, with no MATLAB connection, no root access and no useful apt, but a serviceable Linux container once the right files arrive.
Acknowledgments and disclaimer
The methods and measurements were assembled and tested by the author. A first draft of this post was prepared with Claude and revised with ChatGPT and further revised by the author.
The author has no financial interest in any company or product named here. Nothing in this post is endorsed by, sponsored by, or an official position of the University of Wisconsin-Madison. The affiliation is given for identification only.
Appendix A. build_bundle.sh
#!/bin/sh
# Build a self-contained, network-free bundle of any conda-forge package for
# a Linux x86_64 container. Runs on macOS or Linux. Nothing is installed on
# the host. Requires curl, tar and python3.
#
# Usage: sh build_bundle.sh <name> <package> [package ...]
# e.g. sh build_bundle.sh docs pandoc graphviz
# Output: <name>-bundle-linux64.tar in the current directory.
#
# Env: DROP extended regex of package names to exclude, for example
# DROP='(qt-|qt6|pyqt-|qscintilla2|libllvm|libclang|ghostscript|mesa)'
# GLIBC glibc version the target container provides. Default 2.28.
set -e
[ $# -ge 2 ] || { echo "usage: sh build_bundle.sh <name> <package> [package ...]"; exit 1; }
SCRIPTDIR=$(cd "$(dirname "$0")" && pwd)
NAME=$1; shift
PKGS=$*
WORK="$PWD/${NAME}-bundle-build"
mkdir -p "$WORK"
DROP=${DROP:-'(^$)'}
GLIBC=${GLIBC:-2.28}
# Describe the TARGET container, not this host. Without these a macOS solve
# falls back to pre-2022 builds, and a fast host selects AVX-512 packages.
export CONDA_OVERRIDE_GLIBC=$GLIBC
export CONDA_OVERRIDE_ARCHSPEC=x86_64
ISO='--no-rc --no-env --override-channels' # ignore ~/.condarc and CONDA_* vars
cd "$WORK"
case "$(uname -s)/$(uname -m)" in
Darwin/arm64) HOSTPLAT=osx-arm64 ;;
Darwin/x86_64) HOSTPLAT=osx-64 ;;
Linux/aarch64) HOSTPLAT=linux-aarch64 ;;
*) HOSTPLAT=linux-64 ;;
esac
echo "Host $HOSTPLAT, target linux-64 glibc $GLIBC, packages: $PKGS"
mkdir -p hostbin bundle/pkgs
curl -sSL "https://micro.mamba.pm/api/micromamba/$HOSTPLAT/latest" \
| tar -xj -C hostbin --strip-components=1 bin/micromamba
chmod +x hostbin/micromamba
curl -sSL https://micro.mamba.pm/api/micromamba/linux-64/latest \
| tar -xjO bin/micromamba > bundle/micromamba
chmod +x bundle/micromamba
echo "Solving ..."
export MAMBA_ROOT_PREFIX="$WORK/solveroot"
./hostbin/micromamba create -n solve $ISO --platform linux-64 -c conda-forge \
$PKGS -y --dry-run --json > solve.json
python3 - "$DROP" <<'PY' > urls.txt
import json, re, sys
drop = re.compile('/' + sys.argv[1])
links = json.load(open('solve.json'))['actions']['LINK']
# A host with no glibc override can silently select an old Octave.
octave = next((p for p in links if p['name'] == 'octave'), None)
if octave and int(octave['version'].split('.')[0]) < 10:
raise SystemExit('refusing solved Octave version ' + octave['version'])
out = open('bundle/explicit.txt', 'w')
out.write("# offline install list, linux-64\n# platform: linux-64\n@EXPLICIT\n")
kept = 0
for p in links:
url = p.get('url') or "%s/%s/%s-%s-%s.conda" % (
p['channel'], p['platform'], p['name'], p['version'], p['build_string'])
if drop.search(url):
continue
md5 = p.get('md5')
out.write('@PKGS@/' + url.rsplit('/', 1)[1] + ('#' + md5 if md5 else '') + '\n')
print(url)
kept += 1
out.close()
print("kept %d of %d packages" % (kept, len(links)), file=sys.stderr)
PY
echo "Downloading $(wc -l < urls.txt) packages ..."
xargs -n1 -P8 curl -sS -L -O --output-dir bundle/pkgs < urls.txt
cp "$SCRIPTDIR/install_bundle.sh" bundle/
COPYFILE_DISABLE=1 tar -cf "../${NAME}-bundle-linux64.tar" -C bundle .
cd ..
du -h "${NAME}-bundle-linux64.tar"
cat <<EOF
Bundle: $PWD/${NAME}-bundle-linux64.tar
Upload it, then extract and run install_bundle.sh in the container.
EOF
Appendix B. install_bundle.sh
#!/bin/sh
# Install a bundle built by build_bundle.sh into a prefix inside a container.
# No network. No root. No apt.
# Usage: sh install_bundle.sh [prefix] default: <bundle dir>/prefix
set -e
HERE=$(cd "$(dirname "$0")" && pwd)
PREFIX=${1:-$HERE/prefix}
chmod +x "$HERE/micromamba"
sed "s|@PKGS@|file://$HERE/pkgs|" "$HERE/explicit.txt" > "$HERE/explicit_resolved.txt"
MAMBA_ROOT_PREFIX="$HERE/mroot" "$HERE/micromamba" create -p "$PREFIX" --offline -y \
--file "$HERE/explicit_resolved.txt" >/dev/null
# One launcher that sets the environment and runs any binary from the prefix.
cat > "$HERE/run" <<EOF
#!/bin/sh
export PATH="$PREFIX/bin:\$PATH"
export LD_LIBRARY_PATH="$PREFIX/lib:\${LD_LIBRARY_PATH:-}"
[ -d "$PREFIX/share/octave" ] && export OCTAVE_HOME="$PREFIX"
[ -f "$PREFIX/etc/fonts/fonts.conf" ] && export FONTCONFIG_FILE="$PREFIX/etc/fonts/fonts.conf"
[ -d "$PREFIX/share/gnuplot" ] && export GNUPLOT_DRIVER_DIR="$PREFIX/libexec/gnuplot"
exec "\$@"
EOF
chmod +x "$HERE/run"
echo "Installed to $PREFIX"
echo "Executables:"
ls "$PREFIX/bin" | head -20
echo "Run with: $HERE/run <command> [args]"
Appendix C. octave_selftest.m
% octave_selftest.m
% Self-check for the Octave bundle installed in a ChatGPT container.
% Each test compares against a value known independently of Octave.
% Run: /mnt/data/oct/run octave-cli -q octave_selftest.m
% Writes octave_selftest.png next to the working directory.
npass = 0;
function r = chk(name, got, want, tol)
ok = all(abs(got(:) - want(:)) <= tol);
printf('%-28s %s got %-14.8g want %-14.8g\n', name, ...
merge(ok, 'PASS', 'FAIL'), got(1), want(1));
r = ok;
end
printf('Octave %s on %s\n\n', version(), computer());
% 1. Dense linear algebra. magic(4) is singular, rank 3.
A = magic(4);
npass += chk('rank(magic(4))', rank(A), 3, 0);
% 2. Eigenvalues of the 3x3 second-difference matrix, 2 - 2cos(k pi/4).
T = full(gallery('tridiag', 3, -1, 2, -1));
want = sort(2 - 2*cos((1:3)*pi/4));
npass += chk('eig(tridiag) vs closed form', sort(eig(T))', want, 1e-12);
% 3. Solve a 400x400 system, check the residual and time it.
n = 400; rand('seed', 42);
M = rand(n) + n*eye(n); b = ones(n,1);
t0 = tic; x = M\b; dt = toc(t0);
npass += chk('400x400 solve residual', norm(M*x - b, inf), 0, 1e-10);
printf('%-28s %.3f s\n', 'solve wall time', dt);
% 4. ode45 against y(t) = (2 sin t - cos t)/5 + (6/5)exp(-2t),
% the solution of y' = -2y + sin(t), y(0) = 1.
f = @(t,y) -2*y + sin(t);
opt = odeset('RelTol', 1e-9, 'AbsTol', 1e-11);
[T5, Y5] = ode45(f, [0 5], 1, opt);
ya = @(t) (2*sin(t) - cos(t))/5 + 1.2*exp(-2*t);
npass += chk('ode45 vs analytic at t=5', Y5(end), ya(5), 1e-8);
% 5. Quadrature: integral of exp(-x^2) on [0,Inf) is sqrt(pi)/2.
npass += chk('quadgk vs sqrt(pi)/2', quadgk(@(x) exp(-x.^2), 0, Inf), sqrt(pi)/2, 1e-10);
% 6. FFT of a pure tone: peak bin amplitude n/2 for unit sine.
n = 1024; k = 17; s = sin(2*pi*k*(0:n-1)/n);
S = abs(fft(s));
npass += chk('fft peak bin', S(k+1), n/2, 1e-8);
% 7. Root finding and unconstrained minimization.
npass += chk('fzero cos(x)=x', fzero(@(x) cos(x)-x, [0 1]), 0.73908513321516, 1e-10);
npass += chk('fminsearch Rosenbrock', ...
fminsearch(@(v) 100*(v(2)-v(1)^2)^2 + (1-v(1))^2, [-1.2 1], ...
optimset('TolX',1e-8,'TolFun',1e-10)), [1 1], 1e-3);
% 8. Sparse matrices.
Sp = sparse(1:5, 1:5, 2:6);
npass += chk('sparse trace', full(sum(diag(Sp))), 20, 0);
% 9. MAT file round trip in v7 format, which MATLAB also reads.
Mg = magic(5); save('-v7', 'selftest.mat', 'Mg'); clear Mg
load('selftest.mat');
npass += chk('save/load -v7', sum(Mg(:)), 325, 0);
% 10. Text file I/O.
fid = fopen('selftest.txt', 'w'); fprintf(fid, '%d\n', 1:100); fclose(fid);
v = dlmread('selftest.txt');
npass += chk('fprintf/dlmread', sum(v), 5050, 0);
% 11. Strings, cells, structs, regexp.
c = cellfun(@(s) upper(s), {'ab','cd'}, 'UniformOutput', false);
st = struct('a', {1,2,3});
tok = regexp('Octave 10.3.0', '(\d+)\.(\d+)\.(\d+)', 'tokens');
ok = isequal(c, {'AB','CD'}) && numel(st) == 3 && str2double(tok{1}{1}) == 10;
printf('%-28s %s\n', 'cell/struct/regexp', merge(ok, 'PASS', 'FAIL'));
npass += ok;
% 12. Figure to PNG through the gnuplot toolkit.
warning('off', 'Octave:gnuplot-graphics');
graphics_toolkit('gnuplot');
fh = figure('visible', 'off');
set(fh, 'position', [0 0 900 400]);
x = linspace(0, 4*pi, 400);
plot(x, sin(x), 'b-', 'linewidth', 1.2); hold on
plot(x, sin(x).*exp(-x/6), 'r--', 'linewidth', 1.5);
kk = 1:25:numel(T5);
plot(T5(kk), Y5(kk), 'ko', 'markersize', 5, 'markerfacecolor', 'none');
legend('sin(x)', 'sin(x) exp(-x/6)', 'ode45 solution y(t)', ...
'location', 'northeastoutside');
xlabel('x'); ylabel('amplitude');
title('sin(x), damped sin(x), and ode45 solution of y prime = -2y + sin(t)');
ylim([-1.3 1.3]); grid on
print(fh, 'octave_selftest.png', '-dpngcairo', '-r120');
d = dir('octave_selftest.png');
ok = ~isempty(d) && d.bytes > 5000;
printf('%-28s %s %d bytes\n', 'figure to PNG', merge(ok, 'PASS', 'FAIL'), ...
merge(isempty(d), 0, d.bytes));
npass += ok;
ntot = 13;
printf('\n%d of %d checks passed\n', npass, ntot);
if npass < ntot
printf('FAILURES PRESENT\n');
end
printf('Forge packages installed: %d (expected 0, pkg install needs network)\n', ...
numel(pkg('list')));
Appendix D. Commands to give ChatGPT
Upload the tar and the .m file to one conversation. A literal prompt is less likely to produce a substitute method or imagined output.
Run these shell commands in order and show me all output. Do not substitute Python for any step.
mkdir -p /mnt/data/oct
tar -xf /mnt/data/octave-bundle-linux64.tar -C /mnt/data/oct
sh /mnt/data/oct/install_bundle.sh
/mnt/data/oct/run octave-cli -q /mnt/data/yourscript.m
If a command fails, stop and show the command and its complete error output. Return any PNG files created by the script.
ExifTool and Pandoc follow the same pattern.
/mnt/data/exiftool/run exiftool -s -ImageWidth -ImageHeight image.png
/mnt/data/pandoc/run pandoc -s -f markdown -t rtf -o post.rtf post.md
If the upload is not visible at the expected path, ask for ls -la /mnt/data first. Attach the tar to the conversation in which it will run. If the extract directory is mounted without execute permission, copy the tree to a writable executable directory and install there.