--- # 2026-10-05 Hyperling # Install a local LLM for use offline. # - https://zeroclaw.net/blog/install-zeroclaw # - https://zeroclaw.net/blog/set-up-zeroclaw-bot-ollama-telegram # # Packages Details # - https://archlinux.org/packages/extra/x86_64/zeroclaw/ # - https://archlinux.org/packages/extra/x86_64/ollama/ # # Individual Guides # - https://github.com/zeroclaw-labs/zeroclaw # - https://wiki.archlinux.org/title/Ollama # # Model Explanations # - https://webscraft.org/blog/ollama-na-8-gb-ram-yaki-modeli-pratsyuyut-u-2026?lang=en # # Models # - https://ollama.com/search # # References # - https://docs.zeroclaw.com/master/en/reference/config.html # - https://docs.ollama.com # Currently only testing this on Arch based distros (pacman). - name: Workstation | Linux | Software | LLMs block: - name: Workstation | Linux | Software | LLMs | Facts 01 ansible.builtin.set_fact: ollama: ollama zeroclaw: zeroclaw zerocode: zerocode qwen_code: qwen-code # Good to keep low to prevent swap usage. llm_concurrency: "{{ (ansible_memtotal_mb / 64000) | round(0, method='ceil') | int }}" # Allow having multiple chats open without them just waiting each other. llm_parallel: "{{ (ansible_processor_vcpus / 8) | round(0, method='ceil') | int }}" # Most LLMs take a coefficient of about 1.2 to 1.5 of their file size in # RAM, which is usually less than guessing a usage of weight*1000. # Example: # - qwen3.5:9b would be 6.6GB * 1.3 = 8580, rather than 9000. # Conversely: # - qwen3.5:4b would be 3.3GB * 1.3 = 4290, rather than 4000. llm_size_coef: 1.3 # Take the GB of RAM and use it as the MB of context. llm_context: "{{ ansible_memtotal_mb | int }}" - name: Workstation | Linux | Software | LLMs | Facts 02 ansible.builtin.set_fact: ollama_runit_dir: "{{ runit_script_dir }}/{{ ollama }}" ollama_runit_log_dest: "{{ global_log }}/{{ ollama }}" zeroclaw_runit_dir: "{{ runit_script_dir }}/{{ zeroclaw }}" zeroclaw_runit_log_dest: "{{ global_log }}/{{ zeroclaw }}" - name: Workstation | Linux | Software | LLMs | Facts 03 ansible.builtin.set_fact: ollama_runit_script: "{{ ollama_runit_dir }}/{{ runit_service_script }}" ollama_runit_log_dir: "{{ ollama_runit_dir }}/{{ runit_log_dir }}" ollama_runit_log_config: "{{ ollama_runit_log_dest }}/{{ global_log_config }}" zeroclaw_runit_script: "{{ zeroclaw_runit_dir }}/{{ runit_service_script }}" zeroclaw_runit_log_dir: "{{ zeroclaw_runit_dir }}/{{ runit_log_dir }}" zeroclaw_runit_log_config: "{{ zeroclaw_runit_log_dest }}/{{ global_log_config }}" - name: Workstation | Linux | Software | LLMs | Facts 04 ansible.builtin.set_fact: ollama_runit_log_script: "{{ ollama_runit_log_dir }}/{{ runit_log_script }}" zeroclaw_runit_log_script: "{{ zeroclaw_runit_log_dir }}/{{ runit_log_script }}" - name: Workstation | Linux | Software | LLMs | Facts | LLMs ansible.builtin.set_fact: llm_installs: [] llm_removals: - { "model": "qwen3:4b", "provider": "qwen_30_0040", "size_mb": 4000 } - { "model": "qwen3.6:27b", "provider": "qwen_36_0270", "size_mb": 27000 } - { "model": "qwen3.5:27b", "provider": "qwen_35_0270", "size_mb": "{{ llm_size_coef * 17000 }}" } llm_choices: ### QWEN >3.5 - Need to try! - { "model": "qwen3.8:27b", "provider": "qwen_38_0270", "size_mb": 27000 } - { "model": "qwen3.6:35b", "provider": "qwen_36_0350", "size_mb": 35000 } ### QWEN 3.5 - Good for generic usage and a little coding. - { "model": "qwen3.5:0.8b", "provider": "qwen_35_0008", "size_mb": "{{ llm_size_coef * 1200 }}" } - { "model": "qwen3.5:2b", "provider": "qwen_35_0020", "size_mb": "{{ llm_size_coef * 2700 }}" } - { "model": "qwen3.5:4b", "provider": "qwen_35_0040", "size_mb": "{{ llm_size_coef * 3300 }}" } - { "model": "qwen3.5:9b", "provider": "qwen_35_0090", "size_mb": "{{ llm_size_coef * 6600 }}" } # skip 27b and use 35b - { "model": "qwen3.5:35b", "provider": "qwen_35_0350", "size_mb": "{{ llm_size_coef * 22000 }}" } - { "model": "qwen3.5:122b", "provider": "qwen_35_1220", "size_mb": "{{ llm_size_coef * 81000 }}" } ### QWEN 3.0 Coder - Supposed to be better than 2.5 for machines that can handle it. - { "model": "qwen3-coder:30b", "provider": "qwen_30_coder_0300", "size_mb": "{{ llm_size_coef * 19000 }}" } - { "model": "qwen3-coder:420b", "provider": "qwen_30_coder_4200", "size_mb": "{{ llm_size_coef * 290000 }}" } ### QWEN 2.5 Coder - Ol'reliable! - { "model": "qwen2.5-coder:0.5b", "provider": "qwen_25_coder_0005", "size_mb": "{{ llm_size_coef * 398 }}" } - { "model": "qwen2.5-coder:1.5b", "provider": "qwen_25_coder_0015", "size_mb": "{{ llm_size_coef * 986 }}" } - { "model": "qwen2.5-coder:3b", "provider": "qwen_25_coder_0030", "size_mb": "{{ llm_size_coef * 1900 }}" } - { "model": "qwen2.5-coder:7b", "provider": "qwen_25_coder_0070", "size_mb": "{{ llm_size_coef * 4700 }}" } - { "model": "qwen2.5-coder:14b", "provider": "qwen_25_coder_0140", "size_mb": "{{ llm_size_coef * 9000 }}" } - { "model": "qwen2.5-coder:32b", "provider": "qwen_25_coder_0320", "size_mb": "{{ llm_size_coef * 20000 }}" } ### Mistral, Ministral, and Devstral - By a French company? - { "model": "devstral-small-2:24b", "provider": "devstral_20_0240_small", "size_mb": 24000 } #- mistral:7b #- ministral-3:3b #- ministral-3:8b #- ministral-3:14b ##### TBD ##### ### QWEN 2.5 - Older, but does not have "thinking", tempting! #- qwen2.5:0.5b #- qwen2.5:1.5b #- qwen2.5:3b #- qwen2.5:7b #- qwen2.5:14b #- qwen2.5:32b #- qwen2.5:72b ### Z.ai #- GLM-5.x #- glm4-flash ### Others #- deepseek-r1:1.5b #- deepseek-r1:7b - name: Workstation | Linux | Software | LLMs | Facts | Merge | Print ansible.builtin.debug: var: "{{ item }}" loop: - ansible_processor_vcpus - ansible_memtotal_mb - llm_concurrency - llm_parallel - llm_installs - llm_removals - llm_choices - name: Workstation | Linux | Software | LLMs | Facts | Choices | Sort ansible.builtin.set_fact: llm_installs: >- {{ llm_choices | selectattr('size_mb', 'le', ansible_memtotal_mb) | list }} llm_removals: >- {{ llm_choices | selectattr('size_mb', 'gt', ansible_memtotal_mb) | list }} - name: Workstation | Linux | Software | LLMs | Facts | Merge | Print ansible.builtin.debug: var: "{{ item }}" loop: - llm_installs - llm_removals - name: Workstation | Linux | Software | LLMs | Packages | Install ansible.builtin.package: name: - "{{ zeroclaw }}" - "{{ ollama }}" - "{{ zerocode }}" - "{{ qwen_code }}" state: present - name: Workstation | Linux | Software | LLMs | runit block: - name: Workstation | Linux | Software | LLMs | runit | ollama | Dir | Run ansible.builtin.file: name: "{{ ollama_runit_dir }}" state: directory owner: root mode: "0755" - name: Workstation | Linux | Software | LLMs | runit | ollama | Dir | Log Run ansible.builtin.file: name: "{{ ollama_runit_log_dir }}" state: directory owner: root mode: "0755" - name: Workstation | Linux | Software | LLMs | runit | ollama | Dir | Log Config ansible.builtin.file: name: "{{ ollama_runit_log_dest }}" state: directory owner: "{{ user }}" mode: "0755" - name: Workstation | Linux | Software | LLMs | runit | ollama | Script | Run ansible.builtin.copy: dest: "{{ ollama_runit_script }}" content: | #!/bin/sh -e # Allow pulling and accessing LLMs via tools. exec 2>&1 ### Required ### export HOME={{ user_user.home }} ### Timeouts ### export OLLAMA_LOAD_TIMEOUT=20m # default is 5m export OLLAMA_REQUEST_TIMEOUT=3600 # default 300 (unconfirmed, not in the list of --help vars) ### Resource Usage ### export OLLAMA_KEEP_ALIVE=20m # default is 5m export OLLAMA_MAX_LOADED_MODELS={{ llm_concurrency }} # default is 3 if using CPUs, or 3 * NumGPUs export OLLAMA_NUM_PARALLEL={{ llm_parallel }} # default is 1 and applies per-model (unconfirmed) export OLLAMA_CONTEXT_LENGTH={{ llm_context }} # default is 4096, https://docs.ollama.com/faq#how-can-i-specify-the-context-window-size export OLLAMA_MAX_QUEUE=999 # default is 512 (unconfirmed) ### Prevent Data Loss ### # OLLAMA_NOPRUNE # Default is 0, which causes unfinished pulls to be deleted # after 1 hour of inactivity. To manually clean partials, run: # `rm ~/.ollama/models/blobs/*-partial*` export OLLAMA_NOPRUNE=1 exec chpst -u {{ user }} ollama serve mode: "0755" - name: Workstation | Linux | Software | LLMs | runit | ollama | Script | Log ansible.builtin.copy: dest: "{{ ollama_runit_log_script }}" content: | #!/bin/sh -e exec svlogd -tt {{ ollama_runit_log_dest }} mode: "0755" - name: Workstation | Linux | Software | LLMs | runit | ollama | Script | Log Config ansible.builtin.copy: dest: "{{ ollama_runit_log_config }}" content: | s10000000 # rotate at 10 MB n5 # keep 5 old files dateformat %Y-%m-%d_%H-%M-%S # each run gets its own file. owner: "{{ user }}" mode: "0755" - name: Workstation | Linux | Software | LLMs | runit | zeroclaw | Dir | Run ansible.builtin.file: name: "{{ zeroclaw_runit_dir }}" state: directory owner: root mode: "0755" - name: Workstation | Linux | Software | LLMs | runit | zeroclaw | Dir | Log ansible.builtin.file: name: "{{ zeroclaw_runit_log_dir }}" state: directory owner: root mode: "0755" - name: Workstation | Linux | Software | LLMs | runit | zeroclaw | Dir | Log Config ansible.builtin.file: name: "{{ zeroclaw_runit_log_dest }}" state: directory owner: "{{ user }}" mode: "0755" - name: Workstation | Linux | Software | LLMs | runit | zeroclaw | Script | File ansible.builtin.copy: dest: "{{ zeroclaw_runit_script }}" content: | #!/bin/sh -e # Allow using the ZeroClaw frontend. exec 2>&1 export HOME={{ user_user.home }} export SHELL={{ bash_exec.stdout }} exec chpst -u {{ user }} zeroclaw daemon -v --log-level info mode: "0755" - name: Workstation | Linux | Software | LLMs | runit | zeroclaw | Script | Log ansible.builtin.copy: dest: "{{ zeroclaw_runit_log_script }}" content: | #!/bin/sh -e exec svlogd -tt {{ zeroclaw_runit_log_dest }} mode: "0755" - name: Workstation | Linux | Software | LLMs | runit | zeroclaw | Script | Log Config ansible.builtin.copy: dest: "{{ zeroclaw_runit_log_config }}" content: | s1000000 # rotate at 1 MB n5 # keep 5 old files dateformat %Y-%m-%d_%H-%M-%S # each run gets its own file. mode: "0755" when: ansible_service_mgr == "runit" - name: Workstation | Linux | Software | LLMs | Services | Enable ansible.builtin.service: name: "{{ item }}" state: started enabled: true loop: - "{{ ollama }}" - "{{ zeroclaw }}" - name: Workstation | Linux | Software | LLMs | Services | Verify ansible.builtin.uri: url: "{{ item }}" status_code: 200 timeout: 5 register: health until: health.status == 200 retries: 12 delay: 5 loop: - http://127.0.0.1:11434/ - http://127.0.0.1:42617/health #- name: Workstation | Linux | Software | LLMs | Config | zeroclaw | Root # ansible.builtin.blockinfile: # path: "{{ user_user.home }}/.zeroclaw/config.toml" # marker: "# {mark} ANSIBLE MANAGED BLOCK | zeroclaw | Defaults" # insertbefore: '(?m)^schema_version' # block: | # default_provider = "ollama" # default_model = "qwen3.5:4b" # provider_timeout_secs = 3600 # state: present # create: false # backup: true #- name: Workstation | Linux | Software | LLMs | Config | zeroclaw | Channels | header # ansible.builtin.lineinfile: # path: "{{ user_user.home }}/.zeroclaw/config.toml" # insertbefore: '(?m)^[channels]' # block: | # message_timeout_secs = 3600 # state: present # create: false # backup: true #- name: Workstation | Linux | Software | LLMs | Config | zeroclaw | Channels # ansible.builtin.blockinfile: # path: "{{ user_user.home }}/.zeroclaw/config.toml" # marker: "# {mark} ANSIBLE MANAGED BLOCK | zeroclaw | Channels" # insertbefore: '(?m)^[channels]' # block: | # message_timeout_secs = 3600 # state: present # create: false # backup: true - name: Workstation | Linux | Software | LLMs | Models | ollama | Manual Script ansible.builtin.blockinfile: path: "{{ user_user.home }}/{{ item }}" marker: "# {mark} ANSIBLE MANAGED BLOCK | ollama | Manual Script" content: | function pull_llms() { echo "Manually Pulling Models" {% for llm in llm_installs %} echo -e "\n\n Starting {{ llm.model }} \n\n" ollama pull {{ llm.model }} || { status=$? echo "ERROR: Pull failed with status $status, exiting." return $status } {% endfor %} echo "Done!!" } alias pull-llms="pull_llms " alias pull-models="pull_llms " alias pull-ai="pull_llms " state: present create: false backup: true loop: - .bashrc - .zshrc - name: Workstation | Linux | Software | LLMs | Models | ollama | Manual Script | Message ansible.builtin.debug: msg: <- The next task may take hours, if not days. If you'd like a status bar, I highly recommed reloading your RC file and running `pull-models`. That will give you a much better idea of what's going on. - name: Workstation | Linux | Software | LLMs | Models | ollama | Install ansible.builtin.shell: | set -o pipefail item='{{ item.model }}' if ollama list | grep -q $item; then echo "OKAY" && return else ollama pull $item 1>/dev/null && echo "INSTALLED" && return fi echo "FAILED" loop: "{{ llm_installs }}" register: shell_check changed_when: "'INSTALLED' in shell_check.stdout" failed_when: "'FAILED' in shell_check.stdout" become: true become_user: "{{ user }}" when: llm_installs | length > 0 - name: Workstation | Linux | Software | LLMs | Models | ollama | Remove ansible.builtin.shell: | set -o pipefail item='{{ item.model }}' if ollama list | grep -q $item; then ollama rm $item && echo "INSTALLED" && return else echo "OKAY" && return fi echo "FAILED" loop: "{{ llm_removals }}" register: shell_check changed_when: "'INSTALLED' in shell_check.stdout" failed_when: "'FAILED' in shell_check.stdout" become: true become_user: "{{ user }}" when: llm_removals | length > 0 # Cheat Sheet: https://docs.zeroclaw.com/master/en/providers/configuration.html - name: Workstation | Linux | Software | LLMs | Models | zeroclaw | Add Providers ansible.builtin.blockinfile: path: "{{ user_user.home }}/.zeroclaw/config.toml" marker: "# {mark} ANSIBLE MANAGED BLOCK | zeroclaw | Providers" insertafter: EOF block: | {% for llm in llm_installs %} [providers.models.ollama.{{ llm.provider }}] model = "{{ llm.model }}" timeout_secs = 3600 # 1 hour, default is 120 (2 minutes), issue happens between 5 and 6 minutes though. context_window = {{ llm_context }} {% endfor %} state: present create: false backup: true - name: Workstation | Linux | Software | LLMs | Models | Continue | Config ansible.builtin.copy: dest: "{{ user_user.home }}/.continue/config.yml" content: | # Maintained by `env-ansible/tasks/workstation/linux/software/llm.yml` name: Main Config version: 1.0.0 schema: v1 models: {% for llm in llm_installs %} - name: {{ llm.provider }} provider: ollama model: {{ llm.model }} defaultCompletionOptions: contextLength: {{ llm_context }} maxTokens: {{ llm_context }} - name: {{ llm.provider }}-auto provider: ollama model: {{ llm.model }} defaultCompletionOptions: contextLength: {{ llm_context }} maxTokens: {{ llm_context }} roles: - autocomplete {% endfor %} mode: "0644" when: ansible_pkg_mgr == "pacman"