I wanted to use the ChatGPT/Codex desktop application natively, which worked on NixOS with one command:

NIXPKGS_ALLOW_UNFREE=1 nix run --impure github:numtide/llm-agents.nix#chatgpt

That was temporary testing, but it was not how I wanted to keep it.

My workstation is configured declaratively. If an application becomes part of my normal workflow, I want to put it in the NixOS configuration - not hidden inside terminal history as a command, which would be difficult to remember later on.

The target was simple:

  • install the unified ChatGPT and Codex desktop application;
  • keep its version pinned through flake.lock;
  • install only this package, not every agent in the collection;
  • avoid unnecessarily trusting another binary cache;
  • keep login credentials and application state outside Git.

This is the configuration I ended up using, as of now.

The package source

The package comes from numtide/llm-agents.nix.

Even though the name is as you can see above, adding this repository as a flake input does not install every LLM agent it contains. It only makes its package outputs available to the Nix configuration.

The package selected in this guide is:

llm-agents.packages.${pkgs.stdenv.hostPlatform.system}.chatgpt

That installs the unified ChatGPT and Codex desktop application. Claude Code, Gemini CLI, OpenCode, and the other packages in the collection are not installed unless they are explicitly added.

Warning

This is third-party Nix packaging of the application. It is not an OpenAI-maintained Nix flake.

Add the flake input

My configuration already uses flakes, so I added the new input to flake.nix:

flake.nix
inputs = {
  nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";

  home-manager = {
    url = "github:nix-community/home-manager/release-26.05";
    inputs.nixpkgs.follows = "nixpkgs";
  };

  llm-agents.url = "github:numtide/llm-agents.nix";
};

I deliberately did not add this:

llm-agents.inputs.nixpkgs.follows = "nixpkgs";

The llm-agents.nix project is built and tested against its own pinned nixpkgs-unstable input. My workstation follows the stable nixos-26.05 branch.

Forcing the external flake to use my stable Nixpkgs might look cleaner because it removes a second Nixpkgs input, but upstream specifically warns that this combination can eventually break.

Keeping the upstream pin costs a little more lock-file space. In exchange, I get the dependency combination the package was actually tested with.

That is a reasonable trade-off for a desktop application I want to keep working.

Pass the input to the NixOS modules

My flake passes external package inputs through specialArgs.

The relevant part looks like this:

flake.nix
outputs = {
  nixpkgs,
  home-manager,
  llm-agents,
  ...
}:
{
  nixosConfigurations.nixos = nixpkgs.lib.nixosSystem {
    system = "x86_64-linux";

    specialArgs = {
      inherit llm-agents;
    };

    modules = [
      ./configuration.nix
    ];
  };
};

If your configuration already passes the complete inputs set instead, you can use that structure. There is no reason to reorganize a working flake only for this package.

Add only the ChatGPT package

My normal application packages live in packages.nix, so I added llm-agents to the module arguments:

packages.nix
{ pkgs, llm-agents, ... }:

Then I added the package to environment.systemPackages:

packages.nix
{
  environment.systemPackages = with pkgs; [
    # Other applications

    # Unified ChatGPT and Codex desktop application
    llm-agents.packages.${pkgs.stdenv.hostPlatform.system}.chatgpt
  ];
}

Only that output becomes part of the system closure.

The large upstream package collection is available through the flake, but Nix does not install everything merely because the input exists.

Allow the unfree package

The application is distributed under an unfree license, so Nix must be configured to allow it:

nixpkgs.config.allowUnfree = true;

I already had this enabled previously, for other desktop packages.

Once it is part of the NixOS configuration, I do not need to keep launching it with:

NIXPKGS_ALLOW_UNFREE=1

That environment variable was only useful for the initial non-persistent test.

Update the lock file

After editing flake.nix, I ran:

nix flake lock

The resulting change was larger than one package entry because the lock file also records the external flake’s transitive inputs, including its Nixpkgs revision and build tooling.

That still does not mean every agent was installed. The lock file records inputs; the package list decides what enters the system.

The important part is that the exact upstream revision is now pinned. A future rebuild uses the same package source until I intentionally update it.

What were all the trust prompts?

During the first nix run, Nix asked about these settings:

allow-import-from-derivation
extra-substituters
extra-trusted-public-keys

The last two referred to Numtide’s optional binary cache:

https://cache.numtide.com

Accepting them would allow Nix to download artifacts signed by that cache. This can make builds faster, but it also expands the set of binary caches trusted by the machine.

Tip

I chose the conservative path and did not add that cache or its signing key to my system configuration.

It was not required for the application to work.

I also did not use:

--accept-flake-config

The remote flake may propose settings when it is run directly, but I do not need to copy all of those settings into my NixOS configuration merely to consume one package output.

Build and verify

Before switching the running system, I checked the flake:

nix flake check --no-build

Then I evaluated and built the NixOS configuration using my normal workflow.

For a conventional configuration, that would be:

sudo nixos-rebuild test --flake .#nixos
sudo nixos-rebuild switch --flake .#nixos

After the switch, the application can be launched from the desktop application launcher as ChatGPT, or from a terminal:

chatgpt

Both ChatGPT and Codex are available through the unified application. And, system tray application icon is available on Niri+DMS.

What about updating it later

Because the source is pinned in flake.lock, it will not silently change during an ordinary rebuild.

When I want a newer version, I can update only by using this:

nix flake update llm-agents

Then I review the lock-file change and test the system again.

I avoid updating every flake input at the same time unless that is intentional. A focused update makes it much easier to identify what caused a regression.

Final configuration

The essential configuration is small:

{
  inputs.llm-agents.url = "github:numtide/llm-agents.nix";

  outputs = { nixpkgs, llm-agents, ... }: {
    nixosConfigurations.nixos = nixpkgs.lib.nixosSystem {
      system = "x86_64-linux";

      specialArgs = {
        inherit llm-agents;
      };

      modules = [
        ./configuration.nix
      ];
    };
  };
}

And inside the package module:

{ pkgs, llm-agents, ... }:

{
  nixpkgs.config.allowUnfree = true;

  environment.systemPackages = [
    llm-agents.packages.${pkgs.stdenv.hostPlatform.system}.chatgpt
  ];
}

The final result is exactly how I wanted it:

flowchart TB
    A["🔒 flake.lock"]
    B["📦 Pinned llm-agents.nix"]
    C{"Select a package output"}
    D["✅ chatgpt"]
    E["Other agents remain available<br/>but are not installed"]
    F["💬 ChatGPT + Codex<br/>desktop application"]

    A --> B --> C
    C --> D --> F
    C -. not selected .-> E

No separate install script. No accidental installation of the entire agent collection.

It is simply another declared part of the workstation, which is repeatable in other machines.