[{"title":"Token: One Word, Five Meanings","content":"&ldquo;Send me the token.&rdquo; &ldquo;We ran out of tokens.&rdquo; &ldquo;It&rsquo;s a token on Ethereum.&rdquo;\nThree sentences, three unrelated things, one word. Add the two older meanings from linguistics and compilers and you have five. Anyone building with AI agents runs into most of them in the same afternoon, because an agent spends tokens to call an API with a token.\nThey share one idea: a token is a small thing that stands in for something else. What it stands in for is where they part ways. Each section below has a short animation. They play as you scroll, and the dots let you step through them yourself.\nA pass you show at the door A security token stands in for you. You log in once, the server hands you a string, and from then on the string is shown instead of your password.\nThe most common shape is a JWT, a JSON Web Token.\neyJhbGciOiJIUzI1NiIs….eyJzdWIiOiJrcmlzdG9m….ZWm8JIm2xPKMBiD3BT1q… header{ \"alg\": \"HS256\", \"typ\": \"JWT\" } payload{ \"sub\": \"kristoffer\", \"scope\": \"read:posts\", \"exp\": \"09:00\" } signatureHMAC-SHA256(header + \".\" + payload, secret) 08:15 &nbsp;signature matches → 200 OK 09:01 &nbsp;same string, expired → 401 A security token looks like noise. It is one string, sent along with every request. Two dots split it into three parts. The first two are only base64. Anyone holding the token can read them. The third is a signature. The server recomputes it with a secret only it knows, and a match means nobody changed a byte. The token carries its own expiry. An hour later the same string opens nothing. Two things are worth remembering from that.\nIt is signed, not encrypted. Anyone who gets hold of the token can read what is in it. Never put a secret in the payload. Whoever holds it is you. The server does not ask how you got the string. That is why these expire, and why this is the one kind of token that must never end up in a chat, a log or a screenshot. Not every security token is a JWT. Many are opaque random strings that the server looks up in a database. The role is the same: a stand-in for a login.\nA piece of text with a number A language model&rsquo;s token stands in for a fragment of text. Models do not read letters or words. A tokenizer cuts the text into pieces from a fixed vocabulary and hands over the piece numbers.\nEnglish 8 tokens The791 tokenizer47058 splits41567 unbelie40037vably89234 long1317 words4339.13 Norwegian 14 tokens Token3404is285ering4776en268 del1624er261 opp4083 us603ann1036syn20960lig7864 lange53387 ord6141.13 To you this is a sentence: six words and a full stop. The tokenizer cuts it into pieces it has seen often. Common words survive whole. “Unbelievably” does not. Each piece is swapped for a number. This list of numbers is all the model ever sees. The same sentence in Norwegian is 14 tokens instead of 8. Models read, remember and bill by the token, so the language you write in changes the price. Those are real numbers from GPT-4&rsquo;s tokenizer. Other models cut differently, but the consequences are the same everywhere.\nTokens are the unit of everything. Context windows, rate limits and invoices are all counted in them. A token is not a word. In English it averages about three quarters of one. Rare words, names, code and most other languages cost more. It is not a secret. The vocabulary is public, and the same text gives the same tokens every time. A row in a shared table A blockchain token stands in for value or ownership. The surprise is where it lives: not in your wallet. The wallet holds a key. The token is an entry in a ledger.\nAlice0xA1…9f 10070 signed by 0xA1…9ftransfer(0xB0…2c, 30) Bob0xB0…2c 2050 #1041←#1042←#1043 · transfer A blockchain token is not a thing you hold. It is a number next to your address, in a table that thousands of computers keep a copy of. To send 30, Alice signs a message saying so. Her private key is the only proof of ownership there is. The network puts the message in a block, and every copy of the table adds that block. Now the table says 70 and 50. Nothing travelled anywhere. Everyone agrees on new numbers. A fungible token like a stablecoin is a balance: a number per address. A non-fungible one turns the table around, and maps one item to the address that owns it.\nEither way, copying the token is meaningless. There is nothing to copy. What you can lose is the key that signs for it.\nOne occurrence of a word This is the oldest of the five. In linguistics a token is one occurrence of a word in a text, as opposed to the word itself, which is a type.\ntokens 8 a1rose2is3a4rose5is6a7rose8 types 3 a×3rose×3is×2 A linguist's token is one occurrence of a word. This line has eight. Colour the repeats, and there are only three different words in it. Those are the types. Eight tokens, three types: the ratio is a rough measure of how varied a text is. It sounds like a footnote, but it is the meaning the AI one grew out of. Counting occurrences is how language was first turned into statistics, and a tokenizer is the same idea with smaller pieces.\nThe smallest unit of code A compiler&rsquo;s token stands in for a slice of source code that means something. Before a program can be understood it is cut into keywords, names, operators and literals. That step is the lexer.\ncompiler 13 characters5 tokens constkeyword·xidentifier·=operator·10number;punctuation GPT-4 6 tokens const1040 x865 =284␣22010605;26 A compiler reads source one character at a time. To it, this line is 13 characters. The lexer groups them into the smallest units that mean something, and throws the spaces away. Each token gets a type. From here on the parser only reads the types. Hand the same line to GPT-4 and it is six tokens, not five. One of them is a space the compiler threw away. The last step is the one to keep. The same thirteen characters were cut by two tokenizers into two different sets of tokens, and neither is wrong. A token only means something to the system that cut it.\nSide by side Stands in for Made by If someone copies it Security You, logged in The login server They are you until it expires Language model A fragment of text The tokenizer Nothing. The vocabulary is public Blockchain Value or ownership A contract on a ledger Nothing. Only the key can move it Linguistics One occurrence of a word Whoever wrote the text Nothing Compiler A meaningful slice of code The lexer Nothing Only one row is a secret. That is the practical point of knowing the difference: when a tool, a colleague or an AI agent asks for &ldquo;the token&rdquo;, the first question is which row it is on.\nLook inside one Here is the token from the first animation. Change a character in the payload, or paste one of your own from a test system.\nPaste a JWT eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJrcmlzdG9mZmVyIiwic2NvcGUiOiJyZWFkOnBvc3RzIiwiaWF0IjoxNzk0Mzg0MDAwLCJleHAiOjE3OTQzODc2MDB9.ZWm8JIm2xPKMBiD3BT1q2SX0komezxrpKNgLxi7al1s Decoded in your browser. Nothing is sent anywhere, and the signature is shown, not checked: checking it takes the secret.\nThe header and payload opened without a key. That is the &ldquo;signed, not encrypted&rdquo; part, seen first hand.\nFurther reading Introduction to JSON Web Tokens, and OAuth 2.0 for how they are handed out OpenAI&rsquo;s tokenizer, to cut your own text into model tokens Token standards on Ethereum, including ERC-20 Lexical analysis and the type–token distinction on Wikipedia This post replaces Token Explorer, a small site that used to live at token.kristoffer.dev. That address now lands here.\n","permalink":"https://stage.kristoffer.dev/~/writing/token/","summary":"“Send me the token.” “We ran out of tokens.” “It’s a token on Ethereum.”\nThree sentences, three unrelated things, one word. Add the two older meanings from linguistics and compilers and you have five. Anyone building with AI agents runs into most of them in the same afternoon, because an agent spends tokens to call an API with a token.\nThey share one idea: a token is a small thing that stands in for something else. What it stands in for is where they part ways. Each section below has a short animation. They play as you scroll, and the dots let you step through them yourself."},{"title":"The macOS Apps I Actually Keep","content":"A fresh Mac collects apps fast. These five are the ones still there a month later. Itsycal is the one I put back first: a small icon in the menu bar, and that is the whole reason it stays.\nItsycal Itsycal is a tiny menu bar calendar. The icon is a small block with the day of the month in it. Click it and the month drops down. It reads events from Calendar.app, can show the ISO week number, and can stand in for the system clock if you hand it a date pattern.\nI keep the small icon. The date is always there, the month is one click away, and Calendar stays closed until I need the full thing.\nHold ⌘ and drag the icon toward the clock. macOS hides the menu bar items it cannot fit, starting from the left, and the notch makes that easy. On macOS 26 the icon also needs the switch under System Settings → Menu Bar.\nbash Copy brew install --cask itsycal Rectangle Rectangle, by Ryan Hanson, moves and resizes windows. Drag a window to the edge of the screen and it snaps: the left or right edge takes a half, a corner takes a quarter, the top edge fills the screen. The same actions are keyboard shortcuts, and repeating a shortcut cycles through sizes. On first launch it asks for a set: the recommended one is ⌃⌥ plus an arrow, or keep Spectacle&rsquo;s if your hands already know them.\nIt is the free, open-source app people moved to when Spectacle stopped, and it is built on Spectacle. Rectangle Pro adds more layouts. The free app is the one on my machines.\nbash Copy brew install --cask rectangle Obsidian Obsidian is where notes go. A folder of markdown on disk, linked the way a wiki is linked. The app is local. The files are mine.\nThe vault syncs with git. The machine-specific files under .obsidian/ will fight you across computers, which has its own post: a safe .gitignore for an Obsidian vault.\nbash Copy brew install --cask obsidian Raycast Raycast is the launcher. Open it, type, and you land in an app, a file, a calculation, a snippet, or an extension. Clipboard history lives there too.\nIt can snap windows as well. Rectangle stays installed beside it. Raycast is how I start something. Rectangle is how a window lands once it is open.\nbash Copy brew install --cask raycast Stats Stats, by exelban, is a system monitor in the menu bar. CPU, GPU, memory, disk, network, battery, sensors. Click a module for the detail, and turn off the modules you never look at. If Stats itself starts to show up in the CPU list, turn off Sensors and Bluetooth first. Its own FAQ names them as the most expensive.\nIt sits next to the clock, so a loud build is visible there. Open source, MIT licensed, and the reading I want before I open Activity Monitor.\nThe README shows brew install stats. There is no formula by that name, so Homebrew installs the same cask.\nbash Copy brew install --cask stats The short list bash Copy brew install --cask itsycal rectangle obsidian raycast stats Homebrew gets them on disk. Each one still needs a minute of setup that a script can&rsquo;t do:\nItsycal: allow Calendar access, then drag it next to the clock Rectangle: grant Accessibility under Privacy &amp; Security, and pick a shortcut set Obsidian: open the vault folder instead of creating a new one Raycast: set the hotkey during onboarding Stats: turn off the modules you will never read A calendar I can click, windows that go where I put them, notes in files I own, a launcher, and a monitor that lives next to the clock. That is the set I miss when it is gone.\n","permalink":"https://stage.kristoffer.dev/~/writing/must-have-macos-apps/","summary":"A fresh Mac collects apps fast. These five are the ones still there a month later. Itsycal is the one I put back first: a small icon in the menu bar, and that is the whole reason it stays.\nItsycal Itsycal is a tiny menu bar calendar. The icon is a small block with the day of the month in it. Click it and the month drops down. It reads events from Calendar.app, can show the ISO week number, and can stand in for the system clock if you hand it a date pattern."},{"title":"A Claude Code Status Line That Tells Me When I'll Run Out","content":"The status line is the one piece of UI I look at every few seconds, all day. So I stopped treating it as a place for a model name and a branch, and made it answer the questions I actually have while I work.\nIs the context about to compact? Am I burning through my limit faster than the window allows? When does it reset? What has today cost? And what is the weather doing outside?\nIt lives in my dotfiles, next to everything else, and it is plain bash and jq.\nWhat it looks like text Copy ╭─ Stavern 🌤️ 15° ↗ 2 m/s 💧 82% · Sun 27 Sep 11:52 · CC 2.1.283 ────────── │ │ PROJECT dotfiles main ●2 Opus 5.5 · xhigh │ CONTEXT ▰▰▰▰▰▰▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱ 26% 256k of 1M │ │ 5 HOUR ▰▰▰▰▰▰▰┃▰▰▰▱▱▱▱▱▱▱▱▱▱ 48% ⇡ 11% resets 15:02 in 3h10m ⚠ full by 13:52 │ 7 DAY ▰▰▰▰▱▱▱▱▱▱┃▱▱▱▱▱▱▱▱▱▱ 20% ⇣ 31% resets Wed 22:59 in 3d11h │ │ SESSION $6.84 $4.56/h 61.8k out +412 −87 1h30m ╰─ TODAY $16.82 cache 98% saved $143 It is a panel: a header, then labelled rows whose values line up in columns, grouped into three sections. In the terminal it is Catppuccin Mocha, the same palette as Ghostty, so it looks like part of the window rather than something bolted on.\nThe right edge is open on purpose. Claude Code does not tell a status line how wide the terminal is, and a closed box drawn for one width falls apart at every other.\nRate limits that know my pace This is the part I use most. A percentage on its own does not tell you much. 48% of the five-hour window is fine at hour four and a problem at hour one.\nSo each limit row compares two things: how much I have used, and how much of the window has passed.\nThe ┃ in the bar marks where usage would be if I spread it evenly over the window. ⇡ 11% in red means I am eleven points ahead of that pace. ⇣ 31% in green means that much headroom. resets 15:02 in 3h10m is the clock time and the countdown. I wanted the clock time most: &ldquo;15:02&rdquo; is something I can plan a meeting around; &ldquo;3h10m&rdquo; is arithmetic. ⚠ full by 13:52 only shows up when the current rate would hit 100% before the reset. That is the line that says slow down. The pace idea comes from claude-pace, which is also bash and jq. The math is small enough to read in one go:\nrender.jq Copy # Share of the window that has passed, 0 to 1. (1 - (($reset - $now) / $window)) as $elapsed # Ahead (+) or behind (-) an even pace, in points. | ($used - $elapsed * 100) as $delta # When 100% hits if the rate so far holds. | ($now + (100 - $used) / ($used / ($elapsed * $window))) as $full Weather from my own CLI The header shows the weather because I had the tool lying around. sky is a small Go CLI I wrote on top of MET Norway&rsquo;s API. It already had a JSON output:\nbash Copy sky current --format json The status line must never wait on the network, though. Claude Code redraws it constantly, and a slow fetch would freeze it. So sky runs in the background, at most every ten minutes, and the render just reads whatever is cached:\nstatusline.sh Copy [[ -n &#34;$(find &#34;$stamp&#34; -mmin -&#34;$WEATHER_TTL&#34; 2&gt;/dev/null)&#34; ]] &amp;&amp; return 0 touch &#34;$stamp&#34; ( sky current --format json &gt;&#34;$tmp&#34; &amp;&amp; mv -f &#34;$tmp&#34; &#34;$out&#34; ) &lt;/dev/null &gt;/dev/null 2&gt;&amp;1 &amp; Two details matter there. The stamp is touched before the fetch, so a broken network costs one attempt per ten minutes, not one per redraw. And the background job lets go of stdin and stdout, or Claude Code would sit there waiting for a pipe that never closes.\nOffline for more than two hours, the weather just disappears instead of showing yesterday&rsquo;s sun. No sky installed, same thing. The dotfiles installer adds sky on macOS, so that is rare.\nA bill that is right This whole thing started because the status line I used before lied about money. It priced models it did not know by falling back to the model family, so claude-opus-5-5 matched plain opus and was billed at old Opus prices. A real $7.13 day showed up as $38.63.\nNow prices live in one file, pricing.json. Each model id uses the longest key it starts with, and a model with no known price shows a ~ rather than a guess. The totals come from reading every transcript under ~/.claude/projects, subagents included, counting each API response once.\nThe numbers are covered by fixture tests with amounts worked out by hand. When a new model launches, I add a row.\nCompact mode Ten lines is a lot of screen on a laptop. --compact folds the same data into four:\ntext Copy ╭─ Stavern 🌤️ 15° ↗ 2 m/s · Sun 27 Sep 11:54 · CC 2.1.283 │ PROJECT dotfiles main ●3 Opus 5.5 · xhigh ▰▰▰▱▱▱▱▱▱▱ 26% │ LIMITS 5h ▰▰▰▰┃▰▱▱▱▱▱ 48% ⇡11% ↻15:02 ⚠13:56 · 7d ▰▰▱▱▱┃▱▱▱▱▱ 20% ⇣31% ↻Wed 22:59 ╰─ COST $6.84 session · $4.56/h · $17.24 today · cache 98% It is one flag in ~/.claude/settings.json:\n~/.claude/settings.json Copy &#34;statusLine&#34;: { &#34;type&#34;: &#34;command&#34;, &#34;command&#34;: &#34;bash ~/.claude/statusline/statusline.sh --compact&#34;, &#34;padding&#34;: 0 } How it is built Four files in ~/.claude/statusline/:\nFile Does statusline.sh Reads Claude Code&rsquo;s JSON on stdin, runs git, kicks off the weather fetch, calls jq usage.jq Sums cost and tokens from the transcripts, cached for 15 seconds render.jq Draws the panel pricing.json The only place prices live No Node, no npm package. A refresh takes about 50 ms.\nThe one trick worth stealing from render.jq: pad plain text to its column width before coloring it. An escape code has length but no width, so padding colored text lines up nothing.\nrender.jq Copy def pad($w): . + (&#34; &#34; * ([$w - length, 0] | max)); def col($w; $c): fg($c) + pad($w) + reset; The bug I found on the way While testing this, the status line kept showing aliases I did not recognise. Claude Code&rsquo;s shell had gaa as git add --all, from Oh My Zsh, instead of my git add ., and none of my own aliases at all.\nMy zsh config lives in ~/.config/zsh, and ~/.zshenv sets ZDOTDIR to point zsh there. zsh respects that. Plenty of other tools do not: they read or write ~/.zshrc directly. Claude Code&rsquo;s shell snapshot sourced it. nvm and a CLI installer had appended to it. And my installer had politely left an old ~/.zshrc in place, so those tools were running a different shell than my terminal.\nThe fix is one link:\nbash Copy ln -s ~/.config/zsh/.zshrc ~/.zshrc zsh still reads the file through ZDOTDIR, and everything that hardcodes ~/.zshrc now reads the same one. Anything an installer appends lands in the repo, where git status shows it to me. The installer does this now, after backing up whatever ~/.zshrc was there.\nTry it It comes with the rest of the dotfiles:\nbash Copy curl -fsSL https://kristoffer.dev/dotfiles/install | bash Or pipe a sample into the script to see it without Claude Code:\nbash Copy echo &#39;{&#34;model&#34;:{&#34;display_name&#34;:&#34;Opus 5.5&#34;},&#34;context_window&#34;:{&#34;context_window_size&#34;:1000000,&#34;used_percentage&#34;:26}}&#39; \\ | bash ~/.claude/statusline/statusline.sh The code is in .claude/statusline. Take whatever is useful.\n","permalink":"https://stage.kristoffer.dev/~/writing/claude-code-status-line/","summary":"The status line is the one piece of UI I look at every few seconds, all day. So I stopped treating it as a place for a model name and a branch, and made it answer the questions I actually have while I work.\nIs the context about to compact? Am I burning through my limit faster than the window allows? When does it reset? What has today cost? And what is the weather doing outside?"},{"title":"Dotfiles","content":"This page is the front door to my dotfiles. It is written for one reader first: me, the next time I am sitting in front of a machine that does not know me yet. If you are someone else, welcome — take whatever is useful.\nEverything I care about in a terminal is in that repo: zsh with Powerlevel10k, tmux on a Ctrl+a prefix, Neovim through LazyVim, Ghostty in Catppuccin Mocha, lazygit, a git config that signs every commit, and the settings for the AI agents I work with every day, including a Claude Code status line that gets the bill right. None of it is copied into place. It is linked, so the repo stays the only copy, and a change on one machine is a git pull away from every other.\nThe one-liner bash Copy curl -fsSL https://install.kristoffer.dev/dotfiles | bash It fetches the repo&rsquo;s own install.sh. Piped, that script clones the repo to ~/dotfiles and runs the copy on disk, which:\nInstalls what the configs need. On macOS: Homebrew if it is missing, then Stow, jq, lazygit, tmux and Neovim. On Linux: git, Stow, zsh, tmux and jq, with apt, dnf or pacman. Installs Oh My Zsh and Powerlevel10k when they are not there yet, without letting Oh My Zsh overwrite the .zshrc it is about to be linked to. Links .config into ~/.config and .claude into ~/.claude with Stow. A fresh OS often writes a default config on first launch, and Stow refuses to link over a real file, so anything in the way is moved to ~/.dotfiles-backup/&lt;timestamp&gt;/ first. Nothing is deleted. Adds one line to ~/.zshenv, pointing zsh at its config (more on that below). Checks for the commit signing key, and says how to make one if it is missing, because git is set to sign every commit. Run it twice and nothing breaks: the second run pulls, finds everything installed and linked, and changes nothing.\nOptions Flags go after bash -s --:\nbash Copy curl -fsSL https://install.kristoffer.dev/dotfiles | bash -s -- --dry-run Flag Does --dry-run Prints what would be installed, linked and moved aside, and changes nothing. It clones to a temporary folder to look --link-only Skips every install and only links. Needs git, Stow and zsh already there DOTFILES_DEST=/somewhere/else clones to another folder. From a checkout, it is just ./install.sh.\nNot installed for you: Ghostty (brew install --cask ghostty), and Neovim on Linux, where the distro package is usually older than LazyVim supports.\nRead it before you pipe it Piping a URL into a shell is trusting whoever controls the URL. It is my domain, and I still would not do it blind on someone else&rsquo;s. The script is on GitHub, under four hundred lines of commented bash, next to the configs it links, and CI installs it into a fresh home on macOS and Ubuntu on every change. Everything runs inside a main function called on the last line, so a download cut off halfway runs nothing.\ninstall.kristoffer.dev/dotfiles redirects to the raw file. The address this page used to give, kristoffer.dev/dotfiles/install.sh, still works: it is a few lines that fetch the same script and pass your flags on.\nA new machine, start to finish This is the checklist. It is the reason this page exists.\nmacOS Run the one-liner. It installs Homebrew first if it has to, which asks for your password once and brings the Command Line Tools, and with them git. brew install --cask ghostty, and use Ghostty instead of Terminal.app from now on. It ships with a Nerd Font built in, which is what makes the Powerlevel10k prompt render properly. Open a new Ghostty window. The prompt is already configured: .p10k.zsh is in the repo, so there is no wizard to answer. Set up an SSH key and add it to GitHub twice, once to sign in and once to sign commits (see Signed commits). Until then, git commit fails. gh auth login. nvim. The first launch installs every plugin. Give it a minute and do not panic at the wall of text. Ubuntu, Debian and WSL sudo apt install -y curl Run the one-liner. It installs git, Stow, zsh, tmux and jq with apt, and asks for sudo to do it. Neovim from somewhere newer than apt. apt&rsquo;s Neovim lags years behind what LazyVim needs, so the installer leaves it out. Homebrew for Linux or the Neovim release tarball both work, and so does lazygit from either. chsh -s &quot;$(command -v zsh)&quot;, then log out and in again. Same as macOS from step 4: SSH keys, gh, Neovim&rsquo;s first launch. Ghostty on Linux is a separate install; the terminal config is linked either way and waits for it.\nMachine-specific settings Anything that belongs to one machine — a work proxy, a token, a PATH entry for something only installed here — goes in ~/.config/zsh/.zshrc.local. The .zshrc sources it if it exists, and git ignores it, so it never leaves the machine.\ngit has the same escape hatch: ~/.config/git/config.local is read last, for a work email or a different signing key on one machine.\nWhat&rsquo;s inside Each folder in the repo becomes one symlink:\nPackageLinked toWhat it brings zsh~/.config/zshOh My Zsh + Powerlevel10k, aliases, tmux helpers, CLI completions tmux~/.config/tmuxCtrl+a prefix, vim-style panes, mouse on, OSC 52 clipboard nvim~/.config/nvimLazyVim with .NET, Go, Vue, Tailwind, DAP and Claude Code extras ghostty~/.config/ghosttyCatppuccin Mocha, OSC 52 clipboard, no stray mouse input after sleep git~/.config/gitGlobal git config; every commit and tag signed with an SSH key lazygit~/.config/lazygitNerd Font v3 icons opencode~/.config/opencodeNine agents (companion, reviewer, architect …) and skills claude~/.claudeClaude Code settings and a status line in bash + jq that prices tokens correctly The trick that makes zsh fit this layout is one line the installer adds to ~/.zshenv:\nbash Copy export ZDOTDIR=&#34;$HOME/.config/zsh&#34; zsh reads ~/.zshenv before anything else, and ZDOTDIR tells it to look for the rest of its config in ~/.config/zsh/ — which is a link into the repo. The home directory stays clean: one small file, not a .zshrc, .zprofile and .p10k.zsh side by side.\nIf symlinks are new to you, I wrote about them in Link up your life. Stow is just a tool that makes a whole tree of them in one command, and removes them again with -D.\nBecause ~/.config/zsh is a link into the repo, zsh writes its history and completion cache there too. They are gitignored and kept out of the links, so a shell history, which can hold a pasted token, never ends up in a commit.\nSigned commits ~/.config/git/config signs every commit and tag with an SSH key, ~/.ssh/id_ed25519.pub. No GPG, no agent to babysit: the same kind of key you already use to push. On a new machine:\nbash Copy ssh-keygen -t ed25519 -C &#34;$(git config user.email)&#34; # skip if the key exists gh auth refresh -h github.com -s admin:ssh_signing_key gh ssh-key add ~/.ssh/id_ed25519.pub --type signing --title &#34;$(hostname -s)&#34; GitHub shows the commits as Verified once the key is added as a signing key. An authentication key is not enough, even when it is the very same key, which is the step everyone misses. For git log --show-signature to trust it locally too, add the key to ~/.config/git/allowed_signers.\nA Claude Code status line that gets the bill right ~/.claude/statusline/ is the status line I run under Claude Code. Two lines, bash and jq, no Node, about 40 ms a refresh:\ntext Copy dotfiles main ●1 Opus 5.5 · xhigh ▰▰▱▱▱▱▱▱ 26% 256k/1M session $6.84 · 112k out · +412/-87 · 1h30m │ today $7.52 · cache 98% (saved $63.15) │ 5h 42% ↻1h19m │ 7d 73% ↻3d11h The first line is where I am: folder, branch and uncommitted files, model and effort, and how full the context window is. The second is what it costs: this session, everything since midnight (subagents included), how much of the input came from cache and what that saved, and how close the five-hour and weekly limits are.\nI wrote it because the package I used before got the money wrong. It priced any model it did not know by family name, so claude-opus-5-5 matched plain &ldquo;opus&rdquo; and was billed at Opus 4&rsquo;s $15/$75 per million tokens. One ordinary day showed $38.63. The real figure, checked against the transcripts by hand, was $7.13. Most of the gap was cache reads, charged at seven times their price.\nThis one takes every price from one file, pricing.json, matches a model by its longest known prefix, and prices five-minute and one-hour cache writes and fast mode separately. A model it has no price for shows ~ instead of a guess. bash ~/.claude/statusline/statusline.sh --report breaks the day down by model.\nDaily use Aliases The ones I type without thinking:\nAliasRunsWhy nnvim .Open the editor on the current directory llls -alLong listing, dotfiles included gssgit status -sShort status, the one I type most gaagit add .Stage everything gcmgit commit -mCommit with a message gpshgit pushPush ccclaudeClaude Code ocopencodeOpenCode dtabledocker ps --format table …Containers as a readable table dstartdocker compose up -d &amp;&amp; docker compose logs -fStart the stack and tail it tmxtmx &lt;session&gt;Attach to a tmux session, or offer to create it tmux The prefix is Ctrl+a because Ctrl+b is a reach. Splits are the characters that look like the split:\nKeysDoes Ctrl+aPrefix (instead of Ctrl+b) Ctrl+a |Split left/right Ctrl+a -Split top/bottom Ctrl+a h/j/k/lMove between panes Ctrl+a H/J/K/LResize the pane (repeatable) Ctrl+a [Copy mode, vi keys Ctrl+a rReload the config Clipboard goes over OSC 52, so a yank in tmux or Neovim lands on the laptop&rsquo;s clipboard even when the shell is on a server over SSH. Ghostty is set to allow it.\nChanging things Because everything is a link, there is no &ldquo;apply&rdquo; step. Edit ~/.config/tmux/tmux.conf and you have edited the file in the repo:\nbash Copy nvim ~/.config/zsh/.zshrc # = ~/dotfiles/.config/zsh/.zshrc cd ~/dotfiles &amp;&amp; gss # see what changed gaa &amp;&amp; gcm &#34;zsh: add alias&#34; &amp;&amp; gpsh On every other machine, the one-liner again: it pulls and relinks.\nTo bring a new tool under the repo, move its config folder into ~/dotfiles/.config/ and run ~/dotfiles/install.sh. Stow links it back where it came from.\nTry it in the browser This site has a terminal — press ` anywhere, or the &gt;_ button in the corner. It knows exactly one script, and it is this one.\nThe button at the top types the one-liner into it: curl -fsSL https://install.kristoffer.dev/dotfiles | bash. It runs the same steps as the real installer, with the same output, and then the terminal becomes mine. The colours turn to Catppuccin Mocha. The prompt turns into Powerlevel10k segments. The title bar says Ghostty. And the aliases work — or at least, do the most a browser tab can:\nalias lists them all ll ~/.config shows the links Stow made cat ~/.config/zsh/.zshrc and cat ~/.config/git/config print what is in them cc draws the Claude Code status line n opens Neovim, as far as the splash screen tmux and neofetch do what you would expect dotfiles diff lines up before and after It stays installed as you move around the site, the way a real machine keeps it. dotfiles uninstall — or stow -D, if you prefer the real verb — puts the stock terminal back.\nIt is a toy, but an honest one. curl -fsSL https://install.kristoffer.dev/dotfiles without the pipe prints the actual script, fetched from the repo. The list of packages it links is read from the same data file as the tables on this page, so the demo cannot drift from the documentation.\nTroubleshooting A file I had before is gone. It is in ~/.dotfiles-backup/&lt;timestamp&gt;/, at the same path relative to your home directory. mv it back.\nStow could not link …. The installer moves real files out of the way, and prints Stow&rsquo;s own message for anything else, such as a link into a different checkout. That one is yours to untangle.\nerror: Couldn't load public key ~/.ssh/id_ed25519.pub. git is set to sign, and the key does not exist yet. See Signed commits, or point user.signingKey at another key in ~/.config/git/config.local.\nCommits are signed but GitHub says Unverified. The key is on GitHub as an authentication key only. Add it again with --type signing.\nHomebrew installed, but it is not on PATH. Open a new terminal and run ~/dotfiles/install.sh again.\nThe prompt shows boxes and question marks. The terminal font has no Nerd Font glyphs. Ghostty does out of the box; anything else needs a Nerd Font set by hand.\nStarting over. Unlink everything, and the machine is back to where it was — minus the clone:\nbash Copy cd ~/dotfiles stow -D -t ~/.config .config stow -D -t ~/.claude .claude sed -i.bak &#39;/ZDOTDIR/d&#39; ~/.zshenv Related Installing Oh My Zsh on Linux and macOS Getting started with Neovim Creating your first Homebrew tap The repo on GitHub ","permalink":"https://stage.kristoffer.dev/dotfiles/","summary":"This page is the front door to my dotfiles. It is written for one reader first: me, the next time I am sitting in front of a machine that does not know me yet. If you are someone else, welcome — take whatever is useful.\nEverything I care about in a terminal is in that repo: zsh with Powerlevel10k, tmux on a Ctrl+a prefix, Neovim through LazyVim, Ghostty in Catppuccin Mocha, lazygit, a git config that signs every commit, and the settings for the AI agents I work with every day, including a Claude Code status line that gets the bill right. None of it is copied into place. It is linked, so the repo stays the only copy, and a change on one machine is a git pull away from every other."},{"title":"Installing Oh My Zsh on Linux and macOS","content":"Zsh + Oh My Zsh turns a plain terminal into a fast, autocompleting, good-looking shell. This is the guide I keep coming back to — freshly revised for 2026, covering both Linux and macOS, with the plugins and fonts that actually make a difference.\nRevised (July 2026): rewritten with separate Linux/macOS tracks, current install commands, Nerd Fonts, and the two plugins I install on every machine.\nJump to your platform:\n🐧 Linux (Ubuntu / Debian) 🍎 macOS ⚙️ Configure &amp; make it yours — themes, plugins, fonts (same on both) 🐧 Linux — Ubuntu / Debian Tested on Ubuntu 20.04, 22.04 and 24.04 (and Debian 11/12). Everything here is apt-based.\n1. Update the system bash Copy sudo apt update &amp;&amp; sudo apt full-upgrade -y 2. Install Zsh and dependencies bash Copy sudo apt install -y zsh git curl Confirm it installed:\nbash Copy zsh --version # zsh 5.9 (x86_64-ubuntu-linux-gnu) 3. Install Oh My Zsh bash Copy sh -c &#34;$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)&#34; The installer offers to make Zsh your default shell — say yes. If you skipped it (or it couldn&rsquo;t), set it manually:\nbash Copy chsh -s &#34;$(which zsh)&#34; Log out and back in (or open a new terminal) for the default-shell change to take effect.\nNow jump to Configure &amp; make it yours — the setup from here is identical on every OS.\n🍎 macOS Since macOS Catalina (10.15), Zsh is already the default shell — you may not need to install anything. The one thing worth doing is installing Homebrew so you can manage the newest Zsh, fonts, and tools.\n1. Check what you already have bash Copy zsh --version # macOS ships a recent Zsh out of the box echo &#34;$SHELL&#34; # /bin/zsh means Zsh is already your login shell If $SHELL isn&rsquo;t /bin/zsh, set it:\nbash Copy chsh -s /bin/zsh 2. Install Homebrew (recommended) bash Copy /bin/bash -c &#34;$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)&#34; On Apple Silicon, follow the post-install hint to add Homebrew to your PATH (it lives in /opt/homebrew):\nbash Copy echo &#39;eval &#34;$(/opt/homebrew/bin/brew shellenv)&#34;&#39; &gt;&gt; ~/.zprofile eval &#34;$(/opt/homebrew/bin/brew shellenv)&#34; 3. (Optional) Get the newest Zsh + install Oh My Zsh Apple&rsquo;s bundled Zsh is fine, but Homebrew keeps you on the latest:\nbash Copy brew install zsh git curl Then install Oh My Zsh:\nbash Copy sh -c &#34;$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)&#34; Continue to Configure &amp; make it yours below.\n⚙️ Configure &amp; make it yours Everything below works the same on Linux and macOS. Your config lives in ~/.zshrc:\nbash Copy nano ~/.zshrc # or: code ~/.zshrc / vim ~/.zshrc After any change, reload the shell to apply it:\nbash Copy source ~/.zshrc Pick a theme Oh My Zsh ships with 150+ themes. Set ZSH_THEME in ~/.zshrc:\nbash Copy ZSH_THEME=&#34;robbyrussell&#34; # the clean default # ZSH_THEME=&#34;agnoster&#34; # popular powerline-style (needs a Nerd Font, below) Want the fast, highly-configurable prompt everyone&rsquo;s using now? Try Powerlevel10k:\nbash Copy git clone --depth=1 https://github.com/romkatv/powerlevel10k.git \\ &#34;${ZSH_CUSTOM:-$HOME/.oh-my-zsh/custom}/themes/powerlevel10k&#34; Then set ZSH_THEME=&quot;powerlevel10k/powerlevel10k&quot; and reload — it runs a config wizard on first launch.\nInstall a Nerd Font (fixes icons/glyphs) Powerline and Powerlevel10k themes use special glyphs. Install a Nerd Font and select it in your terminal&rsquo;s settings.\nLinux:\nbash Copy sudo apt install -y fonts-powerline # For full Nerd Font glyphs, download one from nerdfonts.com and drop it in ~/.local/share/fonts, then: fc-cache -f macOS (Homebrew):\nbash Copy brew install --cask font-meslo-lg-nerd-font The two plugins I install everywhere These two transform daily use — autosuggestions (fish-style greyed-out completions from history) and syntax highlighting (commands turn green when valid, red when not).\nbash Copy git clone https://github.com/zsh-users/zsh-autosuggestions \\ &#34;${ZSH_CUSTOM:-$HOME/.oh-my-zsh/custom}/plugins/zsh-autosuggestions&#34; git clone https://github.com/zsh-users/zsh-syntax-highlighting \\ &#34;${ZSH_CUSTOM:-$HOME/.oh-my-zsh/custom}/plugins/zsh-syntax-highlighting&#34; Then enable them in ~/.zshrc. Find the plugins=(...) line and update it:\nbash Copy plugins=(git zsh-autosuggestions zsh-syntax-highlighting) Order matters: keep zsh-syntax-highlighting last in the list — it needs to wrap everything else.\nReload once more and you&rsquo;re done:\nbash Copy source ~/.zshrc That&rsquo;s it You now have a modern Zsh setup — same muscle memory on Linux and macOS. From here, browse the plugin list and add ones for your stack (docker, kubectl, python, npm, …). Each just gets appended to the plugins=(...) line.\nHandy references:\nOh My Zsh install docs — https://ohmyz.sh/#install Themes wiki — https://github.com/ohmyzsh/ohmyzsh/wiki/Themes Plugins wiki — https://github.com/ohmyzsh/ohmyzsh/wiki/Plugins Nerd Fonts — https://www.nerdfonts.com/ ","permalink":"https://stage.kristoffer.dev/~/writing/oh-my-szh/","summary":"Zsh + Oh My Zsh turns a plain terminal into a fast, autocompleting, good-looking shell. This is the guide I keep coming back to — freshly revised for 2026, covering both Linux and macOS, with the plugins and fonts that actually make a difference.\nRevised (July 2026): rewritten with separate Linux/macOS tracks, current install commands, Nerd Fonts, and the two plugins I install on every machine.\nJump to your platform:\n🐧 Linux (Ubuntu / Debian) 🍎 macOS ⚙️ Configure & make it yours — themes, plugins, fonts (same on both) 🐧 Linux — Ubuntu / Debian Tested on Ubuntu 20.04, 22.04 and 24.04 (and Debian 11/12). Everything here is apt-based."},{"title":"CLI-first AI Agents: Why I Build Small Tools Instead of MCP Servers","content":"I build AI agents for a living, and the single most useful thing I&rsquo;ve done for them is stop giving them big interfaces. No SDKs. No sprawling APIs. No MCP servers with twenty tools registered at boot.\nInstead I give them what Unix gave us forty years ago: small, sharp, isolated CLI tools they can compose. One job each. Text in, text out. That&rsquo;s the whole methodology.\nThis post is the short version of why.\nThe context window is the budget Every tool you register with an agent costs tokens. Schemas, descriptions, parameters, examples — they all sit in the system prompt and eat the window before the agent has done any work.\nMCP servers tend to register everything. Twenty tools, full schemas, all loaded whether the agent needs them or not. That&rsquo;s fine for a chatbot. It&rsquo;s expensive for a long-running agent doing real work.\nA CLI is invisible until the agent calls it. --help is on-demand documentation. The model loads only what it needs, when it needs it. That alone is worth the switch.\nSmall tools, sharp boundaries The Linux philosophy translates almost one-to-one to agent design:\nDo one thing well. sky fetches weather. That&rsquo;s it. Compose with pipes. powerctl prices --json | jq '...' works because both sides speak text. Fail loudly. Non-zero exit, stderr, the agent reads it like any other error. Isolate aggressively. One binary, one config file, one set of permissions. Drop it in a container if you want a hard wall. Each tool is a sandbox. The agent gets a verb, not an SDK. I get to control exactly what it can touch.\nMy CLI-first stack I write everything in Go right now. Single static binary, fast cold start, zero runtime dependencies, ships anywhere. The toolchain matches the goal: small, isolated, boring.\nThree I lean on most:\nsky-cli — weather via met.no. Installable via homebrew-sky. powerctl-cli — Tibber and Tibber Pulse energy data. Installable via homebrew-powerctl. cfluence-cli — Confluence pages in and out. Tap scaffolded at homebrew-cfluence, live after the first release. Each one wraps an API I&rsquo;d otherwise have to explain to the agent every session. Now it&rsquo;s just a verb.\nCLI vs MCP — the short table Concern CLI tool MCP server Context window cost Near-zero until invoked Schemas registered upfront State Stateless per call Long-lived session Streaming No Yes Permissions OS-level: env, files, containers Client-level allowlists Secrets Stay in config/env on disk Forwarded through the server Distribution brew, apt, Docker, single binary Client-specific config + runtime Composability Pipes, jq, shell Limited to what the client exposes Cross-client Anything with a shell Any MCP-compatible client Cold start Fresh process per call Already running Best for Agents in a shell, one-shot data, scripting Sessions, streaming, rich resources Pros and cons in one breath CLI pros\nCheap on context — the agent loads only what it uses. OS-native isolation: containers, users, env vars, file modes. Secrets stay local. Distribution is solved (brew install, Docker image, done). Composes with everything Unix already knows. CLI cons\nCold start every call. No streaming. The agent has to read --help (usually fine, sometimes friction). Bad UX in the binary = bad behaviour from the agent. You own the flags. MCP wins when: you need held-open state, streaming, rich return types, or one integration that has to work across many different clients.\nHow an agent actually uses these The pattern is boring on purpose:\nbash Copy # Agent reads the contract powerctl --help # Agent does the work powerctl prices --today --json | jq &#39;.[] | select(.price &gt; 1.5)&#39; In Claude Code&rsquo;s settings, the tool is allowed with one line:\njson Copy { &#34;permissions&#34;: { &#34;allow&#34;: [&#34;Bash(powerctl:*)&#34;] } } That&rsquo;s the entire integration. No registration, no schema, no manifest. The agent inherits whatever the shell can do, scoped to the binaries I let it run.\nRule of thumb If the operation fits in one shell command, ship a CLI. If it needs a session, ship an MCP server.\nFor agent work specifically, I&rsquo;d push it harder: default to CLI. Reach for MCP only when the table above clearly tips the other way. Your context window will thank you.\nOne last thing I built a tiny CLI you can run right now, in your browser, no install required.\nPress ` (backtick) anywhere on this site to open the terminal, then type:\ntext Copy mcp It&rsquo;s a CLI pretending to be an MCP server, pretending to be a CLI. The joke is the whole post compressed into one command.\nIf you&rsquo;d rather run something real, brew install kristofferrisa/sky/sky and ask it for the weather. That&rsquo;s the stack.\n","permalink":"https://stage.kristoffer.dev/~/writing/cli-over-mcp/","summary":"I build AI agents for a living, and the single most useful thing I’ve done for them is stop giving them big interfaces. No SDKs. No sprawling APIs. No MCP servers with twenty tools registered at boot.\nInstead I give them what Unix gave us forty years ago: small, sharp, isolated CLI tools they can compose. One job each. Text in, text out. That’s the whole methodology.\nThis post is the short version of why."},{"title":"The Vault","content":"You found it.\nNot many do.\nThis page is for people who are curious enough to look past the surface — who find themselves typing commands into a terminal widget on a personal blog at an odd hour, just to see what happens.\nThat kind of curiosity is the best kind.\nThings I actually believe On software: The goal is not to write code. The goal is to eliminate the need for code. Every line you write is a liability you have to carry. Write less. Delete more.\nOn complexity: Most systems fail not because they were too simple, but because they became too complicated to understand. Simplicity is the hardest skill to develop — and the most undervalued.\nOn craft: There is a difference between code that works and code that communicates. The former is a product. The latter is a craft. Both matter. But when you have to choose, choose the one a future-you can read at 2am without wanting to cry.\nOn learning: The thing you are embarrassed not to know is the thing most worth learning next. Lean into the discomfort.\nOn building things: Ship something ugly. Then make it less ugly. Repeat. Perfection is a destination that keeps moving. Progress is a path you actually walk.\nSome lines that stuck &ldquo;The purpose of abstracting is not to be vague, but to create a new semantic level in which one can be absolutely precise.&rdquo; — Edsger W. Dijkstra\n&ldquo;Any fool can write code that a computer can understand. Good programmers write code that humans can understand.&rdquo; — Martin Fowler\n&ldquo;We are what we repeatedly do. Excellence, then, is not an act, but a habit.&rdquo; — Aristotle\n&ldquo;Programs must be written for people to read, and only incidentally for machines to execute.&rdquo; — Abelson &amp; Sussman\nWhy this exists Because some things are worth hiding just a little. Not to be exclusive — but to reward the people who go looking.\nIf you got here through the terminal hack challenge: well done. If you used the Konami code: old school, respect. If you just happened to find the URL by guessing: you&rsquo;ve got the right instincts.\nWelcome. Stay as long as you like.\n— Kristoffer\n","permalink":"https://stage.kristoffer.dev/hidden/vault/","summary":"You found it.\nNot many do.\nThis page is for people who are curious enough to look past the surface — who find themselves typing commands into a terminal widget on a personal blog at an odd hour, just to see what happens.\nThat kind of curiosity is the best kind.\nThings I actually believe On software: The goal is not to write code. The goal is to eliminate the need for code. Every line you write is a liability you have to carry. Write less. Delete more."},{"title":"Building a Hardened Linux Environment for Your OpenClaw AI Agent","content":" A practical, opinionated guide to running an autonomous AI assistant on a dedicated home lab server — with proper isolation, network control, and custom tooling.\nGoals You want a dedicated, always-on Linux machine that runs an AI agent (OpenClaw) with the following properties:\nIsolated execution environment — The agent runs under a dedicated Linux user (agent-openclaw) with strict permission boundaries. It cannot escalate to root, cannot access other users&rsquo; data, and operates within well-defined filesystem and process boundaries.\nCustom CLI tool integration — Your own GoLang tools (Sky, PowerCtl) are available to the agent, authenticated via a mix of environment variables and Bitwarden CLI-fetched secrets, injected at runtime without persisting tokens on disk in plaintext.\nNetwork-level control via Tailscale — The agent machine joins your tailnet but can only reach specific services (n8n, Bitwarden, Obsidian vault shares) and specific external API domains. All other network traffic is denied by default. You have full visibility and control over what the agent can access.\nHardened OS — The Ubuntu Server 24.04 LTS installation follows security best practices: minimal attack surface, automatic security updates, audit logging, and strict firewall rules.\nOpenClaw as the runtime — The agent uses OpenClaw&rsquo;s Gateway, channels, and skills platform. It runs as a systemd user service, restarts on failure, and exposes its Gateway only over Tailscale.\nSteps Overview Base Ubuntu Server Installation &amp; Hardening User Accounts &amp; Isolation Model Tailscale Network Control Secrets Management Installing Custom CLI Tools Installing OpenClaw systemd Service Configuration AppArmor Profile (Optional but Recommended) Operational Checklist Part 1: Base Ubuntu Server Installation &amp; Hardening 1.1 — Minimal Installation Start with a Ubuntu Server 24.04 LTS minimal installation. During install:\nChoose &ldquo;Ubuntu Server (minimized)&rdquo; if offered Set up LVM with encryption (LUKS) for the root partition — this protects data at rest if the physical machine is stolen Create a single admin user (e.g., kristoffer) — this is your human operator account, not the agent Enable OpenSSH server during install After first boot, update everything:\nbash Copy sudo apt update &amp;&amp; sudo apt upgrade -y sudo apt install -y unattended-upgrades apt-listchanges 1.2 — Enable Automatic Security Updates Configure unattended-upgrades to automatically apply security patches:\nbash Copy sudo dpkg-reconfigure -plow unattended-upgrades Verify the configuration:\nbash Copy cat /etc/apt/apt.conf.d/20auto-upgrades Should contain:\ntext Copy APT::Periodic::Update-Package-Lists &#34;1&#34;; APT::Periodic::Unattended-Upgrade &#34;1&#34;; Edit /etc/apt/apt.conf.d/50unattended-upgrades to enable automatic reboots if needed (for kernel updates):\ntext Copy Unattended-Upgrade::Automatic-Reboot &#34;true&#34;; Unattended-Upgrade::Automatic-Reboot-Time &#34;04:00&#34;; 1.3 — SSH Hardening Edit /etc/ssh/sshd_config:\nbash Copy sudo tee /etc/ssh/sshd_config.d/hardening.conf &lt;&lt; &#39;EOF&#39; # Disable password auth — keys only PasswordAuthentication no ChallengeResponseAuthentication no # Disable root login PermitRootLogin no # Limit to your admin user only AllowUsers kristoffer # Reduce attack surface X11Forwarding no MaxAuthTries 3 LoginGraceTime 30 ClientAliveInterval 300 ClientAliveCountMax 2 EOF sudo systemctl restart sshd Make sure your SSH key is in ~kristoffer/.ssh/authorized_keys before disabling password auth.\n1.4 — Firewall with UFW Set up a strict firewall. The agent machine should only be reachable via Tailscale, not on the LAN directly:\nbash Copy sudo ufw default deny incoming sudo ufw default deny outgoing # Allow SSH from LAN as fallback (restrict to your subnet) sudo ufw allow from 192.168.1.0/24 to any port 22 proto tcp # Allow Tailscale interface (all traffic over tailnet is controlled by ACLs) sudo ufw allow in on tailscale0 sudo ufw allow out on tailscale0 # Allow DNS resolution sudo ufw allow out 53/udp sudo ufw allow out 53/tcp # Allow HTTPS out (needed for API calls, package management) sudo ufw allow out 443/tcp # Allow HTTP out (some package repos) sudo ufw allow out 80/tcp # Allow Tailscale&#39;s own UDP traffic (WireGuard) sudo ufw allow out 41641/udp sudo ufw enable Note: We allow outgoing HTTPS/HTTP here at the OS level but will use Tailscale ACLs to control which hosts the agent can actually reach. UFW is the coarse filter; Tailscale ACLs are the fine-grained one.\n1.5 — Install Essential Packages bash Copy sudo apt install -y \\ build-essential \\ curl \\ git \\ jq \\ tmux \\ htop \\ auditd \\ fail2ban \\ apparmor \\ apparmor-utils \\ acl \\ rsync 1.6 — Enable Audit Logging The audit daemon records system calls and can track what the agent user does:\nbash Copy sudo systemctl enable auditd sudo systemctl start auditd # Add rules to monitor the agent user&#39;s activities sudo tee /etc/audit/rules.d/agent-openclaw.rules &lt;&lt; &#39;EOF&#39; # Monitor all commands executed by the agent user (UID will be set after user creation) # We&#39;ll update this after creating the agent user -a always,exit -F arch=b64 -S execve -F uid=2001 -k agent-exec -a always,exit -F arch=b64 -S openat -F uid=2001 -F dir=/etc -k agent-etc-access -a always,exit -F arch=b64 -S openat -F uid=2001 -F dir=/home/kristoffer -k agent-home-snoop EOF 1.7 — Configure fail2ban bash Copy sudo systemctl enable fail2ban sudo systemctl start fail2ban Default config protects SSH. Good enough for a home lab.\nPart 2: User Accounts &amp; Isolation Model The core principle: the agent runs as a dedicated, unprivileged Linux user with no sudo access and carefully controlled filesystem permissions.\n2.1 — Create the Agent User bash Copy # Create a system group for agent users sudo groupadd --gid 2000 agents # Create the dedicated agent user sudo useradd \\ --uid 2001 \\ --gid agents \\ --create-home \\ --home-dir /home/agent-openclaw \\ --shell /bin/bash \\ --comment &#34;OpenClaw AI Agent&#34; \\ agent-openclaw # Lock the password — no direct login, only su/sudo from admin sudo passwd -l agent-openclaw Now update the audit rules with the correct UID:\nbash Copy sudo augenrules --load 2.2 — Directory Structure Create a clean workspace layout for the agent:\nbash Copy # Agent home structure sudo -u agent-openclaw mkdir -p /home/agent-openclaw/{.config,.local/bin,workspace,tools,secrets} # The .openclaw directory (OpenClaw&#39;s data) sudo -u agent-openclaw mkdir -p /home/agent-openclaw/.openclaw/{workspace/skills,credentials} # Lock down permissions — agent owns their home, nobody else reads it sudo chmod 750 /home/agent-openclaw sudo chmod 700 /home/agent-openclaw/secrets 2.3 — Restrict Agent Filesystem Access Use filesystem permissions and ACLs to ensure the agent user cannot wander:\nbash Copy # Agent cannot read other users&#39; homes sudo chmod 750 /home/kristoffer # Agent cannot write to system directories (already the case by default, but verify) # Agent cannot access /root sudo chmod 700 /root # Create a shared directory for files you want the agent to access sudo mkdir -p /srv/agent-shared sudo chown kristoffer:agents /srv/agent-shared sudo chmod 2770 /srv/agent-shared # setgid so new files inherit group 2.4 — Restrict Process Visibility Prevent the agent user from seeing other users&rsquo; processes:\nbash Copy # Mount /proc with hidepid echo &#34;proc /proc proc defaults,hidepid=2,gid=$(getent group agents | cut -d: -f3) 0 0&#34; | \\ sudo tee -a /etc/fstab # Apply now sudo mount -o remount,hidepid=2,gid=2000 /proc With hidepid=2, the agent-openclaw user can only see its own processes, not yours or other services&rsquo;.\n2.5 — Resource Limits with systemd and cgroups Prevent the agent from consuming all system resources. We&rsquo;ll configure this later when we set up the systemd service, but the key limits will be:\nMemory: Capped at e.g. 4GB (adjust for your hardware) CPU: Limited to specific cores or percentage Max open files: Reasonable limit No ability to create new user namespaces (prevents container escapes) Part 3: Tailscale Network Control This is where you get surgical control over what the agent machine can talk to.\n3.1 — Install Tailscale bash Copy curl -fsSL https://tailscale.com/install.sh | sh # Start tailscale and authenticate sudo tailscale up --ssh 3.2 — Tag the Agent Machine In your Tailscale admin console (https://login.tailscale.com/admin/acls), you&rsquo;ll tag this machine. Tags are the foundation of ACL control.\nFirst, register the tag in your ACL policy:\njsonc Copy { &#34;tagOwners&#34;: { &#34;tag:agent&#34;: [&#34;autogroup:admin&#34;], &#34;tag:homelab&#34;: [&#34;autogroup:admin&#34;], &#34;tag:services&#34;: [&#34;autogroup:admin&#34;], }, } Apply the tag to the agent machine:\nbash Copy sudo tailscale up --advertise-tags=tag:agent Tag your other machines appropriately:\nYour n8n server → tag:services Your Bitwarden/Vaultwarden instance → tag:services Your NAS/Obsidian file server → tag:homelab Your personal machines → no tag (they&rsquo;re your user identity) 3.3 — Tailscale ACL Policy This is the core of your network control. The ACL policy defines exactly what the agent machine can reach:\njsonc Copy { &#34;tagOwners&#34;: { &#34;tag:agent&#34;: [&#34;autogroup:admin&#34;], &#34;tag:homelab&#34;: [&#34;autogroup:admin&#34;], &#34;tag:services&#34;: [&#34;autogroup:admin&#34;], }, &#34;acls&#34;: [ // Your personal machines can reach everything { &#34;action&#34;: &#34;accept&#34;, &#34;src&#34;: [&#34;autogroup:member&#34;], &#34;dst&#34;: [&#34;*:*&#34;], }, // Agent can reach specific services on your tailnet { &#34;action&#34;: &#34;accept&#34;, &#34;src&#34;: [&#34;tag:agent&#34;], &#34;dst&#34;: [ &#34;tag:services:8080&#34;, // n8n webhook port &#34;tag:services:443&#34;, // Vaultwarden/Bitwarden HTTPS &#34;tag:homelab:445&#34;, // SMB for Obsidian vault &#34;tag:homelab:22&#34;, // SSH/rsync for file access ], }, // Agent can reach the OpenClaw Gateway on itself (loopback via tailscale) { &#34;action&#34;: &#34;accept&#34;, &#34;src&#34;: [&#34;tag:agent&#34;], &#34;dst&#34;: [&#34;tag:agent:18789&#34;], }, // Services can talk back to agent (for webhooks from n8n, etc.) { &#34;action&#34;: &#34;accept&#34;, &#34;src&#34;: [&#34;tag:services&#34;], &#34;dst&#34;: [&#34;tag:agent:18789&#34;], }, ], // DNS configuration — control what domains the agent can resolve &#34;dns&#34;: { &#34;nameservers&#34;: [&#34;100.100.100.100&#34;], // Tailscale&#39;s MagicDNS &#34;domains&#34;: [&#34;your-tailnet.ts.net&#34;], &#34;magicDNS&#34;: true, }, } 3.4 — Controlling Internet Access (Exit Node Pattern) Tailscale ACLs control tailnet traffic, but what about the agent reaching external APIs (Anthropic, OpenAI, GitHub, etc.)? There are two approaches:\nOption A: Allow outbound HTTPS at the OS level (simpler)\nYou already have UFW allowing outbound 443. The agent process can reach external APIs directly. This is the simplest approach and works well if you trust the agent not to exfiltrate data to random domains.\nOption B: Proxy through an exit node with DNS filtering (more control)\nSet up a machine on your tailnet as an exit node with DNS-level filtering (e.g., Pi-hole or NextDNS):\nbash Copy # On your DNS/proxy machine sudo tailscale up --advertise-exit-node --advertise-tags=tag:services # On the agent machine — route all traffic through the exit node sudo tailscale up --exit-node=&lt;exit-node-ip&gt; --advertise-tags=tag:agent Then configure Pi-hole/NextDNS to only allow specific domains:\napi.anthropic.com api.openai.com github.com registry.npmjs.org pypi.org Your custom API domains This guide assumes Option A as the starting point. You can layer on Option B later.\n3.5 — Mount Obsidian Vault via Tailscale Since the agent needs access to your Obsidian vault on another machine, set up an SMB or NFS mount over Tailscale:\nbash Copy # Install CIFS utilities sudo apt install -y cifs-utils # Create mount point sudo mkdir -p /mnt/obsidian-vault # Create a credentials file (readable only by root) sudo tee /etc/samba/.obsidian-creds &lt;&lt; &#39;EOF&#39; username=your-smb-user password=your-smb-password EOF sudo chmod 600 /etc/samba/.obsidian-creds # Add fstab entry — mounts over Tailscale IP, accessible by agent user echo &#34;//your-nas.your-tailnet.ts.net/obsidian /mnt/obsidian-vault cifs credentials=/etc/samba/.obsidian-creds,uid=agent-openclaw,gid=agents,file_mode=0640,dir_mode=0750,nofail 0 0&#34; | \\ sudo tee -a /etc/fstab sudo mount -a Now the agent can read the vault at /mnt/obsidian-vault but the mount credentials are stored securely.\nPart 4: Secrets Management Your GoLang tools (Sky, PowerCtl) use a mix of environment variables and Bitwarden CLI for authentication. Here&rsquo;s how to set this up securely for the agent.\n4.1 — Install Bitwarden CLI bash Copy # Install as the agent user sudo -u agent-openclaw bash &lt;&lt; &#39;SETUP&#39; cd /home/agent-openclaw curl -fsSL &#34;https://vault.bitwarden.com/download/?app=cli&amp;platform=linux&#34; -o bw.zip unzip bw.zip -d .local/bin/ rm bw.zip chmod +x .local/bin/bw SETUP 4.2 — Bitwarden Session Management The agent should never store the Bitwarden master password on disk. Instead, use a systemd-managed session that unlocks at service start via a pre-stored API key or session token.\nCreate a wrapper script that the agent&rsquo;s tools will use to fetch secrets:\nbash Copy sudo -u agent-openclaw tee /home/agent-openclaw/tools/bw-get-secret.sh &lt;&lt; &#39;SCRIPT&#39; #!/usr/bin/env bash # Fetch a secret from Bitwarden by item name and field # Usage: bw-get-secret.sh &#34;item-name&#34; [field-name] # If field-name is omitted, returns the password field set -euo pipefail ITEM_NAME=&#34;$1&#34; FIELD_NAME=&#34;${2:-password}&#34; if [ -z &#34;${BW_SESSION:-}&#34; ]; then echo &#34;ERROR: BW_SESSION not set. Bitwarden is not unlocked.&#34; &gt;&amp;2 exit 1 fi if [ &#34;$FIELD_NAME&#34; = &#34;password&#34; ]; then bw get password &#34;$ITEM_NAME&#34; --session &#34;$BW_SESSION&#34; elif [ &#34;$FIELD_NAME&#34; = &#34;username&#34; ]; then bw get username &#34;$ITEM_NAME&#34; --session &#34;$BW_SESSION&#34; else bw get item &#34;$ITEM_NAME&#34; --session &#34;$BW_SESSION&#34; | jq -r &#34;.fields[] | select(.name==\\&#34;$FIELD_NAME\\&#34;) | .value&#34; fi SCRIPT sudo chmod 750 /home/agent-openclaw/tools/bw-get-secret.sh 4.3 — Environment File for Static Secrets For environment variables that don&rsquo;t change often, use a systemd EnvironmentFile that is readable only by root and the agent:\nbash Copy # Create the directory first sudo mkdir -p /etc/openclaw # Then create the environment file sudo tee /etc/openclaw/agent.env &lt;&lt; &#39;EOF&#39; # OpenClaw configuration ANTHROPIC_API_KEY=sk-ant-xxxxx OPENCLAW_GATEWAY_PORT=18789 # Static tool tokens (rotate periodically) SKY_API_ENDPOINT=https://your-api.example.com POWERCTL_CONFIG_PATH=/home/agent-openclaw/.config/powerctl/config.yaml # Bitwarden API key for automatic unlock BW_CLIENTID=user.xxxxx BW_CLIENTSECRET=xxxxx EOF # Set secure permissions sudo chmod 600 /etc/openclaw/agent.env sudo chown root:agents /etc/openclaw/agent.env sudo chmod 640 /etc/openclaw/agent.env 4.4 — Runtime Secret Injection Script Create a startup script that unlocks Bitwarden and populates dynamic secrets before OpenClaw starts:\nbash Copy sudo -u agent-openclaw tee /home/agent-openclaw/tools/agent-startup.sh &lt;&lt; &#39;SCRIPT&#39; #!/usr/bin/env bash # Runs before OpenClaw Gateway starts # Unlocks Bitwarden and exports dynamic secrets set -euo pipefail export PATH=&#34;/home/agent-openclaw/.local/bin:$PATH&#34; # Login to Bitwarden using API key (set via EnvironmentFile) if [ -n &#34;${BW_CLIENTID:-}&#34; ] &amp;&amp; [ -n &#34;${BW_CLIENTSECRET:-}&#34; ]; then bw login --apikey 2&gt;/dev/null || true export BW_SESSION=$(bw unlock --passwordenv BW_MASTER_PASSWORD --raw 2&gt;/dev/null || echo &#34;&#34;) if [ -z &#34;$BW_SESSION&#34; ]; then echo &#34;WARNING: Could not unlock Bitwarden vault. Dynamic secrets unavailable.&#34; &gt;&amp;2 else echo &#34;Bitwarden vault unlocked successfully.&#34; # Fetch dynamic secrets and export them export SKY_ACCESS_TOKEN=$(bw get password &#34;Sky CLI Token&#34; --session &#34;$BW_SESSION&#34; 2&gt;/dev/null || echo &#34;&#34;) export POWERCTL_TOKEN=$(bw get password &#34;PowerCtl API Token&#34; --session &#34;$BW_SESSION&#34; 2&gt;/dev/null || echo &#34;&#34;) fi fi # Execute the actual command (OpenClaw gateway) exec &#34;$@&#34; SCRIPT sudo chmod 750 /home/agent-openclaw/tools/agent-startup.sh Part 5: Installing Custom CLI Tools 5.1 — Install Your GoLang Tools Since Sky and PowerCtl are your own GoLang binaries, install them into the agent&rsquo;s local bin:\nbash Copy # Copy pre-built binaries (adjust paths to where you build/distribute them) sudo cp /path/to/sky /home/agent-openclaw/.local/bin/sky sudo cp /path/to/powerctl /home/agent-openclaw/.local/bin/powerctl sudo chown agent-openclaw:agents /home/agent-openclaw/.local/bin/{sky,powerctl} sudo chmod 750 /home/agent-openclaw/.local/bin/{sky,powerctl} Alternatively, if you want the agent machine to pull them from a Git repo or your own release server:\nbash Copy # If you publish releases on GitHub sudo -u agent-openclaw bash &lt;&lt; &#39;INSTALL&#39; cd /home/agent-openclaw/.local/bin curl -fsSL https://github.com/your-org/sky/releases/latest/download/sky-linux-amd64 -o sky curl -fsSL https://github.com/your-org/powerctl/releases/latest/download/powerctl-linux-amd64 -o powerctl chmod +x sky powerctl INSTALL 5.2 — Configure Tool Access for OpenClaw OpenClaw needs to know about these tools. Add them to the agent&rsquo;s workspace:\nbash Copy sudo -u agent-openclaw tee /home/agent-openclaw/.openclaw/workspace/TOOLS.md &lt;&lt; &#39;TOOLSDOC&#39; # Available CLI Tools ## Sky Location: `~/.local/bin/sky` Purpose: [Your description of what Sky does] Usage: `sky [command] [flags]` Authentication: Uses $SKY_ACCESS_TOKEN environment variable. ## PowerCtl Location: `~/.local/bin/powerctl` Purpose: [Your description of what PowerCtl does] Usage: `powerctl [command] [flags]` Authentication: Uses $POWERCTL_TOKEN environment variable and config at $POWERCTL_CONFIG_PATH. ## Bitwarden CLI Location: `~/.local/bin/bw` Purpose: Fetch secrets from your Bitwarden vault. Usage: `bw get password &#34;item-name&#34;` (requires $BW_SESSION to be set) Note: Session is established at agent startup. Use `bw-get-secret.sh` wrapper for convenience. TOOLSDOC 5.3 — PATH Configuration Ensure the agent&rsquo;s PATH includes the local bin:\nbash Copy sudo -u agent-openclaw tee -a /home/agent-openclaw/.bashrc &lt;&lt; &#39;BASHRC&#39; # Agent tool paths export PATH=&#34;$HOME/.local/bin:$PATH&#34; # Node.js (installed in Part 6) export PATH=&#34;$HOME/.local/share/nvm/versions/node/v22/bin:$PATH&#34; BASHRC Part 6: Installing OpenClaw 6.1 — Install Node.js 22 OpenClaw requires Node.js &gt;= 22:\nbash Copy # Install nvm as the agent user sudo -u agent-openclaw bash &lt;&lt; &#39;NVMSETUP&#39; curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash export NVM_DIR=&#34;$HOME/.nvm&#34; source &#34;$NVM_DIR/nvm.sh&#34; nvm install 22 nvm use 22 nvm alias default 22 # Install pnpm globally npm install -g pnpm NVMSETUP 6.2 — Install OpenClaw bash Copy sudo -u agent-openclaw bash &lt;&lt; &#39;CLAWSETUP&#39; export NVM_DIR=&#34;$HOME/.nvm&#34; source &#34;$NVM_DIR/nvm.sh&#34; # Install OpenClaw globally npm install -g openclaw@latest # Or from source for more control: # git clone https://github.com/openclaw/openclaw.git ~/openclaw-src # cd ~/openclaw-src # pnpm install # pnpm ui:build # pnpm build CLAWSETUP 6.3 — Configure OpenClaw Create the minimal configuration:\nbash Copy sudo -u agent-openclaw mkdir -p /home/agent-openclaw/.openclaw sudo -u agent-openclaw tee /home/agent-openclaw/.openclaw/openclaw.json &lt;&lt; &#39;CONFIG&#39; { &#34;agent&#34;: { &#34;model&#34;: &#34;anthropic/claude-opus-4-5&#34; }, &#34;gateway&#34;: { &#34;port&#34;: 18789, &#34;bind&#34;: &#34;loopback&#34;, &#34;tailscale&#34;: { &#34;mode&#34;: &#34;serve&#34; }, &#34;auth&#34;: { &#34;mode&#34;: &#34;password&#34;, &#34;password&#34;: &#34;CHANGE_ME_TO_SOMETHING_STRONG&#34; } }, &#34;channels&#34;: { &#34;telegram&#34;: { &#34;botToken&#34;: &#34;YOUR_TELEGRAM_BOT_TOKEN&#34; } }, &#34;browser&#34;: { &#34;enabled&#34;: false } } CONFIG Key decisions here:\nbind: &quot;loopback&quot; — Gateway only listens on 127.0.0.1. External access is via Tailscale Serve only. tailscale.mode: &quot;serve&quot; — Exposes the Gateway dashboard over your tailnet with HTTPS, but not to the public internet. auth.mode: &quot;password&quot; — Adds a password requirement even for tailnet access. browser.enabled: false — Disabled by default on a headless server. Enable if you install a headless Chrome later. 6.4 — Run the Onboarding Wizard bash Copy sudo -iu agent-openclaw openclaw onboard Follow the wizard to set up your preferred channels (Telegram, Discord, etc.), identity, and workspace.\nPart 7: systemd Service Configuration 7.1 — Create the Service Unit bash Copy sudo tee /etc/systemd/system/openclaw-agent.service &lt;&lt; &#39;UNIT&#39; [Unit] Description=OpenClaw AI Agent Gateway After=network-online.target tailscaled.service Wants=network-online.target StartLimitIntervalSec=300 StartLimitBurst=5 [Service] Type=simple User=agent-openclaw Group=agents # Environment files EnvironmentFile=/etc/openclaw/agent.env # Use the startup script to inject dynamic secrets ExecStart=/home/agent-openclaw/tools/agent-startup.sh \\ /home/agent-openclaw/.nvm/versions/node/v22/bin/node \\ /home/agent-openclaw/.nvm/versions/node/v22/bin/openclaw \\ gateway --port 18789 --verbose # Restart policy Restart=on-failure RestartSec=10 # Working directory WorkingDirectory=/home/agent-openclaw # Resource limits (adjust for your hardware) MemoryMax=4G MemoryHigh=3G CPUQuota=200% TasksMax=256 LimitNOFILE=8192 # Security hardening NoNewPrivileges=true ProtectSystem=strict ProtectHome=tmpfs BindPaths=/home/agent-openclaw BindReadOnlyPaths=/mnt/obsidian-vault PrivateTmp=true ProtectKernelTunables=true ProtectKernelModules=true ProtectControlGroups=true RestrictSUIDSGID=true RestrictNamespaces=true RestrictRealtime=true LockPersonality=true SystemCallArchitectures=native # Logging StandardOutput=journal StandardError=journal SyslogIdentifier=openclaw-agent [Install] WantedBy=multi-user.target UNIT This is a heavily hardened service configuration. Key security properties:\nNoNewPrivileges=true — The agent process cannot gain additional privileges ProtectSystem=strict — The entire filesystem is read-only except explicitly allowed paths ProtectHome=tmpfs — All home directories are hidden, then we bind-mount only the agent&rsquo;s home BindPaths — Only the agent&rsquo;s home is writable BindReadOnlyPaths — Obsidian vault is mounted read-only RestrictNamespaces=true — Cannot create user/network namespaces (prevents container escape patterns) MemoryMax=4G — Hard memory cap 7.2 — Enable and Start bash Copy sudo systemctl daemon-reload sudo systemctl enable openclaw-agent.service sudo systemctl start openclaw-agent.service # Check status sudo systemctl status openclaw-agent.service sudo journalctl -u openclaw-agent.service -f 7.3 — Log Monitoring Set up structured log access:\nbash Copy # View agent logs sudo journalctl -u openclaw-agent.service --since &#34;1 hour ago&#34; # View audit logs for the agent user sudo ausearch -ua 2001 --start today # Follow live sudo journalctl -u openclaw-agent.service -f Part 8: AppArmor Profile (Optional but Recommended) For an additional layer of mandatory access control, create an AppArmor profile for the OpenClaw process:\nbash Copy sudo tee /etc/apparmor.d/openclaw-agent &lt;&lt; &#39;APPARMOR&#39; #include &lt;tunables/global&gt; profile openclaw-agent /home/agent-openclaw/.nvm/versions/node/*/bin/node { #include &lt;abstractions/base&gt; #include &lt;abstractions/nameservice&gt; # Node.js and OpenClaw /home/agent-openclaw/.nvm/** rix, /home/agent-openclaw/.local/bin/** rix, # Agent workspace (read/write) /home/agent-openclaw/ r, /home/agent-openclaw/** rw, /home/agent-openclaw/.openclaw/** rw, # Obsidian vault (read-only) /mnt/obsidian-vault/ r, /mnt/obsidian-vault/** r, # Shared directory /srv/agent-shared/** rw, # Deny sensitive paths deny /etc/shadow r, deny /etc/passwd w, deny /home/kristoffer/** rw, deny /root/** rw, # Network network inet stream, network inet dgram, network inet6 stream, network inet6 dgram, # Temp /tmp/** rw, /var/tmp/** rw, } APPARMOR sudo apparmor_parser -r /etc/apparmor.d/openclaw-agent Part 9: Operational Checklist Verification Steps After completing the setup, verify each layer:\nbash Copy # 1. User isolation — agent cannot read your home sudo -u agent-openclaw ls /home/kristoffer # Should: Permission denied # 2. Process isolation — agent cannot see your processes sudo -u agent-openclaw ps aux # Should: Only see agent-openclaw&#39;s own processes # 3. Tailscale connectivity — agent can reach allowed services sudo -u agent-openclaw curl -s https://your-n8n.your-tailnet.ts.net/healthz # Should: 200 OK # 4. Tailscale isolation — agent cannot reach random tailnet machines sudo -u agent-openclaw curl -s https://your-desktop.your-tailnet.ts.net:22 # Should: Timeout/refused (if not in ACL) # 5. Tools work — Sky and PowerCtl are available sudo -u agent-openclaw /home/agent-openclaw/.local/bin/sky --version sudo -u agent-openclaw /home/agent-openclaw/.local/bin/powerctl --version # 6. OpenClaw is running sudo systemctl status openclaw-agent.service curl -s http://127.0.0.1:18789/health # 7. Audit trail is recording sudo ausearch -ua 2001 --start today -i | head -20 Maintenance Tasks Task Frequency Command OS security updates Daily (auto) unattended-upgrades handles this OpenClaw updates Weekly/as needed sudo -iu agent-openclaw npm update -g openclaw Rotate API keys Monthly Update /etc/openclaw/agent.env and Bitwarden Review audit logs Weekly sudo ausearch -ua 2001 --start recent -i Check Tailscale ACLs After changes Tailscale admin console Backup agent workspace Weekly rsync to your NAS Update Sky/PowerCtl As released Replace binaries in .local/bin/ Backup the Agent State bash Copy # Cron job to backup the agent&#39;s workspace and config sudo tee /etc/cron.d/openclaw-backup &lt;&lt; &#39;CRON&#39; 0 3 * * * root rsync -az --delete /home/agent-openclaw/.openclaw/ /srv/backups/openclaw/ 2&gt;&amp;1 | logger -t openclaw-backup CRON Architecture Summary text Copy ┌─────────────────────────────────────────────────────────────────┐ │ Your Tailnet (WireGuard) │ │ │ │ ┌──────────────┐ ┌──────────────┐ ┌───────────────────────┐ │ │ │ Your Desktop │ │ NAS/Files │ │ Services (n8n, BW) │ │ │ │ (personal) │ │ tag:homelab │ │ tag:services │ │ │ └──────┬───────┘ └──────┬───────┘ └───────────┬───────────┘ │ │ │ │ │ │ │ │ Tailscale ACLs control all traffic │ │ │ │ │ │ │ │ ┌──────┴─────────────────┴───────────────────────┴──────────┐ │ │ │ Agent Machine (tag:agent) │ │ │ │ Ubuntu 24.04 LTS — Hardened │ │ │ │ │ │ │ │ ┌──────────────────────────────────────────────────────┐ │ │ │ │ │ user: agent-openclaw (uid 2001) │ │ │ │ │ │ │ │ │ │ │ │ ┌─────────────┐ ┌────────┐ ┌──────────────────┐ │ │ │ │ │ │ │ OpenClaw │ │ Sky │ │ PowerCtl │ │ │ │ │ │ │ │ Gateway │ │ CLI │ │ CLI │ │ │ │ │ │ │ │ :18789 │ │ │ │ │ │ │ │ │ │ │ └──────┬───────┘ └───┬────┘ └────────┬─────────┘ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ └──────────────┴─────────────────┘ │ │ │ │ │ │ │ │ │ │ │ │ │ Secrets: env vars + Bitwarden CLI runtime fetch │ │ │ │ │ │ Filesystem: home + /mnt/obsidian-vault (ro) │ │ │ │ │ │ Resources: 4GB RAM, 200% CPU, 256 tasks max │ │ │ │ │ └──────────────────────────────────────────────────────┘ │ │ │ │ │ │ │ │ Security layers: │ │ │ │ ✓ UFW firewall (coarse) │ │ │ │ ✓ Tailscale ACLs (fine-grained) │ │ │ │ ✓ systemd sandboxing (ProtectSystem, NoNewPrivileges) │ │ │ │ ✓ AppArmor MAC profile │ │ │ │ ✓ hidepid=2 (process isolation) │ │ │ │ ✓ auditd (full command logging) │ │ │ │ ✓ LUKS disk encryption │ │ │ └────────────────────────────────────────────────────────────┘ │ │ │ │ External APIs (Anthropic, OpenAI, etc.) │ │ ← Allowed via UFW outbound HTTPS │ │ ← Optional: DNS filtering via exit node │ └─────────────────────────────────────────────────────────────────┘ What&rsquo;s Next Once this foundation is running, you can extend it:\nAdd more agent users — Create agent-codex, agent-research, etc., each with their own UID, Tailscale tag, and ACL rules Enable OpenClaw browser control — Install headless Chromium and set browser.enabled: true for web automation Set up OpenClaw skills — Build custom skills that wrap your Sky and PowerCtl tools with natural language interfaces Add n8n webhooks — Use OpenClaw&rsquo;s webhook surface to receive triggers from your n8n automations DNS filtering — Deploy a Pi-hole exit node for granular domain-level control over the agent&rsquo;s internet access Prometheus/Grafana monitoring — Monitor the agent&rsquo;s resource usage, API costs, and activity patterns The key insight of this architecture is defense in depth: no single layer is responsible for security. The agent is constrained by Linux user permissions, systemd sandboxing, AppArmor mandatory access control, Tailscale network ACLs, and OS-level firewalling — all working together. If any one layer has a gap, the others compensate.\n","permalink":"https://stage.kristoffer.dev/~/writing/hardened-linux-environment-for-openclaw-ai-agent/","summary":" A practical, opinionated guide to running an autonomous AI assistant on a dedicated home lab server — with proper isolation, network control, and custom tooling.\nGoals You want a dedicated, always-on Linux machine that runs an AI agent (OpenClaw) with the following properties:\nIsolated execution environment — The agent runs under a dedicated Linux user (agent-openclaw) with strict permission boundaries. It cannot escalate to root, cannot access other users’ data, and operates within well-defined filesystem and process boundaries."},{"title":"Creating Your First Homebrew Tap: A Complete Guide","content":"If you&rsquo;ve built a command-line tool and want to make it easy for users to install, Homebrew is one of the best distribution methods available. In this guide, I&rsquo;ll walk you through creating your own Homebrew tap—a custom repository that allows users to install your software with a simple brew install command.\nSteps Overview Prepare Your Binary Releases Create Your Tap Repository Write Your Formula Get SHA256 Checksums Push Your Formula Test Your Tap Share Your Tap What is Homebrew? Homebrew is the most popular package manager for macOS, and it also works on Linux. It allows users to install software from the command line with simple commands like brew install wget. Think of it as an app store for command-line tools and applications.\nUnderstanding Homebrew Vocabulary Before we dive in, let&rsquo;s clarify some key terms:\nFormula: A Ruby script that tells Homebrew how to download, build, and install a piece of software. It&rsquo;s essentially a recipe for installing your application.\nTap: A third-party repository of formulas. While Homebrew has a main repository (homebrew-core) with thousands of formulas, taps allow anyone to create their own custom repository. The name comes from the idea of &ldquo;tapping a keg&rdquo;—you&rsquo;re adding a new source of software.\nBottle: A pre-compiled binary version of software. Instead of compiling from source, users can download a ready-to-use binary, making installation much faster.\nCask: A formula specifically for installing GUI applications (like browsers or editors) rather than command-line tools.\nCellar: The directory where Homebrew installs software (/usr/local/Cellar on Intel Macs, /opt/homebrew/Cellar on Apple Silicon).\nWhy Create a Tap? Creating your own tap is perfect when:\nYou want to distribute your own CLI tools or applications Your software isn&rsquo;t popular enough (yet!) for homebrew-core You need faster iteration without going through the homebrew-core review process You want to maintain multiple versions or experimental releases Prerequisites Before starting, make sure you have:\nA GitHub account A CLI tool or application you want to distribute Binary releases of your application (we&rsquo;ll cover this) Basic familiarity with Git and GitHub Step 1: Prepare Your Binary Releases Homebrew formulas need downloadable binaries. The most common approach is to use GitHub Releases.\nUsing GoReleaser (for Go projects) If your project is written in Go, GoReleaser makes this incredibly easy. Add a .goreleaser.yml file:\nyaml Copy builds: - env: - CGO_ENABLED=0 goos: - linux - darwin goarch: - amd64 - arm64 archives: - format: tar.gz name_template: &gt;- {{ .ProjectName }}_{{ .Version }}_ {{- title .Os }}_ {{- if eq .Arch &#34;amd64&#34; }}x86_64 {{- else }}{{ .Arch }}{{ end }} checksum: name_template: &#34;checksums.txt&#34; Then create a release:\nbash Copy git tag -a v1.0.0 -m &#34;First release&#34; git push origin v1.0.0 goreleaser release --clean Manual Binary Releases If you&rsquo;re not using Go or GoReleaser, you&rsquo;ll need to:\nBuild binaries for different platforms (macOS Intel, macOS ARM, Linux x86_64, Linux ARM64) Package them as .tar.gz archives Upload them to a GitHub release Generate SHA256 checksums Example for generating checksums:\nbash Copy shasum -a 256 your-app-darwin-arm64.tar.gz shasum -a 256 your-app-darwin-amd64.tar.gz shasum -a 256 your-app-linux-amd64.tar.gz Step 2: Create Your Tap Repository Your tap must be a GitHub repository with a specific naming convention:\nCreate a new repository named homebrew-&lt;your-tap-name&gt;\nFor example: homebrew-tools or homebrew-myapp The homebrew- prefix is required Create the directory structure:\nbash Copy mkdir -p Formula That&rsquo;s it! Homebrew only needs the Formula directory.\nStep 3: Write Your Formula Create a file at Formula/&lt;your-app&gt;.rb. Here&rsquo;s a complete example for a CLI tool called &ldquo;sky&rdquo;:\nruby Copy class Sky &lt; Formula desc &#34;A powerful CLI tool for cloud management&#34; homepage &#34;https://github.com/yourusername/sky-cli&#34; version &#34;1.0.0&#34; on_macos do if Hardware::CPU.arm? url &#34;https://github.com/yourusername/sky-cli/releases/download/v1.0.0/sky_1.0.0_Darwin_arm64.tar.gz&#34; sha256 &#34;abc123...your-arm64-sha256-here&#34; else url &#34;https://github.com/yourusername/sky-cli/releases/download/v1.0.0/sky_1.0.0_Darwin_x86_64.tar.gz&#34; sha256 &#34;def456...your-x86_64-sha256-here&#34; end end on_linux do if Hardware::CPU.arm? url &#34;https://github.com/yourusername/sky-cli/releases/download/v1.0.0/sky_1.0.0_Linux_arm64.tar.gz&#34; sha256 &#34;ghi789...your-linux-arm64-sha256-here&#34; else url &#34;https://github.com/yourusername/sky-cli/releases/download/v1.0.0/sky_1.0.0_Linux_x86_64.tar.gz&#34; sha256 &#34;jkl012...your-linux-x86_64-sha256-here&#34; end end def install bin.install &#34;sky&#34; end test do system &#34;#{bin}/sky&#34;, &#34;--version&#34; end end Understanding the Formula Let&rsquo;s break down each section:\nclass Sky &lt; Formula: The class name must match your filename (sky.rb → Sky class), capitalized desc: A short, one-line description of your tool homepage: Your project&rsquo;s website or GitHub repository version: The version number on_macos / on_linux: Platform-specific configurations Hardware::CPU.arm?: Detects ARM architecture (M1/M2 Macs, ARM Linux) url: The download URL for your binary sha256: The checksum to verify download integrity install: Instructions for installing your binary (typically just copying to the bin directory) test: A simple test to verify the installation works Step 4: Get SHA256 Checksums You need SHA256 checksums for each binary. The easiest way:\nbash Copy # Download and checksum in one command curl -sL https://github.com/yourusername/app/releases/download/v1.0.0/app_Darwin_arm64.tar.gz | shasum -a 256 # Or if you have the file locally shasum -a 256 app_Darwin_arm64.tar.gz Copy these checksums into your formula where indicated.\nStep 5: Push Your Formula bash Copy git add Formula/your-app.rb git commit -m &#34;Add formula for your-app v1.0.0&#34; git push origin main Step 6: Test Your Tap Now you can test your tap locally:\nbash Copy # Add your tap brew tap yourusername/your-tap-name # Install your application brew install your-app # Test it works your-app --version Step 7: Share Your Tap Users can now install your application with just two commands:\nbash Copy brew tap yourusername/your-tap-name brew install your-app Or in a single command:\nbash Copy brew install yourusername/your-tap-name/your-app Updating Your Formula When you release a new version:\nCreate a new GitHub release with updated binaries Get the new SHA256 checksums Update your formula with new version, URLs, and checksums Commit and push Users can then update with:\nbash Copy brew update brew upgrade your-app Advanced Tips Auto-update with Dependabot Add .github/dependabot.yml to your tap repository:\nyaml Copy version: 2 updates: - package-ecosystem: &#34;github-actions&#34; directory: &#34;/&#34; schedule: interval: &#34;weekly&#34; Adding Dependencies If your app requires other software:\nruby Copy depends_on &#34;python@3.11&#34; depends_on &#34;git&#34; Multiple Binaries If your release contains multiple executables:\nruby Copy def install bin.install &#34;main-app&#34; bin.install &#34;helper-tool&#34; bin.install &#34;utility&#34; end Configuration Files Install configuration or documentation files:\nruby Copy def install bin.install &#34;your-app&#34; etc.install &#34;config.yml&#34; =&gt; &#34;your-app/config.yml&#34; doc.install &#34;README.md&#34; end Troubleshooting Common Issues &ldquo;Formula not found&rdquo; error Make sure your repository name starts with homebrew- and your formula is in the Formula/ directory.\nSHA256 mismatch If you get a checksum error, recalculate your SHA256:\nbash Copy curl -sL YOUR_URL | shasum -a 256 Cached formula not updating Force Homebrew to refresh:\nbash Copy brew untap yourusername/your-tap brew tap yourusername/your-tap Or clear the cache:\nbash Copy rm -rf $(brew --repository)/Library/Taps/yourusername/homebrew-your-tap brew tap yourusername/your-tap Downloads failing with 404 Verify your release assets are publicly accessible. Go to your GitHub release page and try downloading the files manually. If your repository is private, the assets won&rsquo;t be accessible via Homebrew.\nBest Practices Test before publishing: Always test your formula locally before pushing Use semantic versioning: Follow semver (1.0.0, 1.1.0, 2.0.0) Keep formulas simple: Don&rsquo;t add unnecessary complexity Document installation: Add installation instructions to your project&rsquo;s README Maintain checksums: Always verify SHA256 checksums match your binaries Support multiple platforms: Provide binaries for both Intel and ARM architectures Add a test block: Include a simple test to verify installation Next Steps Now that you have your tap set up, consider:\nAdding your tap to awesome lists and directories Writing documentation for your users Automating formula updates with CI/CD Eventually submitting to homebrew-core once your project gains traction Have questions or run into issues? Check out the official Homebrew documentation.\n","permalink":"https://stage.kristoffer.dev/~/writing/guide-to-creating-your-first-homebrew-tap/","summary":"If you’ve built a command-line tool and want to make it easy for users to install, Homebrew is one of the best distribution methods available. In this guide, I’ll walk you through creating your own Homebrew tap—a custom repository that allows users to install your software with a simple brew install command.\nSteps Overview Prepare Your Binary Releases Create Your Tap Repository Write Your Formula Get SHA256 Checksums Push Your Formula Test Your Tap Share Your Tap What is Homebrew? Homebrew is the most popular package manager for macOS, and it also works on Linux. It allows users to install software from the command line with simple commands like brew install wget. Think of it as an app store for command-line tools and applications."},{"title":"Spark from NDC Oslo: Events as Internal APIs","content":"💥 Spark: Events as Internal APIs At NDC Oslo, one quote from James Eastham really stuck with me during his talk:\n&ldquo;Think about your events as internal APIs.&rdquo;\nThis simple mindset shift has deep implications for how we design, evolve, and document event-driven systems.\nJust like internal APIs, events deserve:\nClear contracts — schemas, versioning, and well-defined intent Ownership — someone needs to own the lifecycle and semantics Documentation — make it obvious what the event means and when it&rsquo;s emitted Stability — no breaking changes without coordination This spark reminded me that reliability in event-driven systems isn’t just about infra or retries — it’s about treating your events as first-class citizens in your architecture.\n🔗 Talk: So You Want to Maintain a Reliable Event Driven System — James Eastham\nThis is part of a series of Sparks — short takeaways and memorable insights from conferences, talks, and daily learnings.\n","permalink":"https://stage.kristoffer.dev/~/writing/sparks-from-ndc-events-as-apis/","summary":"💥 Spark: Events as Internal APIs At NDC Oslo, one quote from James Eastham really stuck with me during his talk:\n“Think about your events as internal APIs.”\nThis simple mindset shift has deep implications for how we design, evolve, and document event-driven systems.\nJust like internal APIs, events deserve:\nClear contracts — schemas, versioning, and well-defined intent Ownership — someone needs to own the lifecycle and semantics Documentation — make it obvious what the event means and when it’s emitted Stability — no breaking changes without coordination This spark reminded me that reliability in event-driven systems isn’t just about infra or retries — it’s about treating your events as first-class citizens in your architecture."},{"title":"Link Sparks: The Power of Ten - Rules for Developing Safety Critical Code","content":"The Power of Ten - Rules for Developing Safety Critical Code The &ldquo;Power of Ten&rdquo; is a set of coding rules designed by NASA&rsquo;s Gerard J. Holzmann to improve the safety, reliability, and maintainability of safety-critical software. The document outlines ten essential rules for writing robust, verifiable code in critical systems, with a focus on simplicity, strict control flow, memory management, and code clarity.\nThese rules are especially relevant when the cost of failure is high, like in space missions or medical devices. The emphasis on tool-based compliance checks and practical coding practices make these guidelines highly valuable for developers working on critical software.\nFor an engaging breakdown and reaction to the rules, check out ThePrimeagen&rsquo;s video, where he reads and analyzes the document. Whether you prefer to dive straight into the document or watch a developer&rsquo;s perspective on it, both links offer insightful and informative content.\nHighlights:\nTen essential rules for safe and reliable coding. Focus on simplicity, defensive programming, and code clarity. Insights from NASA&rsquo;s approach to safety-critical systems. Video reaction and breakdown by ThePrimeagen. Explore the document here (PDF)\nWatch ThePrimeagen&rsquo;s reaction video\n","permalink":"https://stage.kristoffer.dev/~/writing/power-of-ten/","summary":"The Power of Ten - Rules for Developing Safety Critical Code The “Power of Ten” is a set of coding rules designed by NASA’s Gerard J. Holzmann to improve the safety, reliability, and maintainability of safety-critical software. The document outlines ten essential rules for writing robust, verifiable code in critical systems, with a focus on simplicity, strict control flow, memory management, and code clarity."},{"title":"Obsidian + GitHub: Safe .gitignore Setup","content":"Syncing Obsidian vaults with Git works great until machine-specific files create conflicts or you accidentally commit sensitive notes. This guide gives you two battle-tested .gitignore configurations and a safety checklist to avoid common pitfalls.\nQuick take: Use the Strict setup to eliminate config conflicts entirely. Add .gitattributes for cleaner diffs. Keep your repo private unless you&rsquo;re building a public knowledge base.\nRecommended .gitignore A) Minimal (Keep plugin settings, drop noise) Keeps plugin settings and config across devices. Excludes cache and workspace files.\ngitignore Copy # OS and editor clutter .DS_Store ._* Thumbs.db ehthumbs.db Icon? .Spotlight-V100 .Trashes *.swp *.swo *.tmp # Obsidian .trash/ .obsidian/cache/ .obsidian/workspace* .obsidian/updates.json # Common plugin caches .obsidian/plugins/*/cache/ .obsidian/plugins/*/.cache/ .obsidian/plugins/*/node_modules/ B) Strict (Zero config conflicts) Excludes all .obsidian/ config. No merge conflicts, ever. Set up plugins once per device.\ngitignore Copy # OS and editor clutter .DS_Store ._* Thumbs.db ehthumbs.db Icon? .Spotlight-V100 .Trashes *.swp *.swo *.tmp # Obsidian - exclude all configuration .obsidian/ .trash/ Recommended .gitattributes Add this to get better diffs and prevent line-ending issues:\ngitattributes Copy # Ensure consistent line endings * text=auto *.md text # Treat binary files properly *.png binary *.jpg binary *.jpeg binary *.gif binary *.pdf binary Safety Checklist Keep your repo private - Your notes likely contain personal information Review before first commit - Run git status and git diff to verify what you&rsquo;re committing Scan for sensitive data - Check for API keys, passwords, or private details Test on a clone first - Clone to a temp folder and verify the workflow before syncing devices Write meaningful commits - &ldquo;Updated notes&rdquo; helps no one. &ldquo;Added project planning docs&rdquo; helps future you Basic Sync Workflow bash Copy # Pull changes from other devices git pull # Stage and commit your changes git add . git commit -m &#34;Update notes from [device name]&#34; # Push to GitHub git push For automatic syncing, consider using Git plugins like Obsidian Git that can auto-commit and push at regular intervals.\n","permalink":"https://stage.kristoffer.dev/~/writing/obsidian-gitignore/","summary":"Syncing Obsidian vaults with Git works great until machine-specific files create conflicts or you accidentally commit sensitive notes. This guide gives you two battle-tested .gitignore configurations and a safety checklist to avoid common pitfalls.\nQuick take: Use the Strict setup to eliminate config conflicts entirely. Add .gitattributes for cleaner diffs. Keep your repo private unless you’re building a public knowledge base.\nRecommended .gitignore A) Minimal (Keep plugin settings, drop noise) Keeps plugin settings and config across devices. Excludes cache and workspace files."},{"title":"Managing Git Submodules: A Quick Guide","content":"Git submodules are a powerful feature that allows you to include and manage other repositories as part of your main repository. This is especially useful for projects where you want to use external repositories (e.g., a custom Neovim configuration) while keeping them independent. In this post, we&rsquo;ll cover how to set up, update, and manage Git submodules with a practical example.\nWhat Are Git Submodules? A submodule in Git is a pointer to a specific commit of another repository. It allows you to include that repository as a part of your main project without merging its content directly into your repository.\nHow to Set Up a Git Submodule Add a Submodule to Your Repository\nUse the git submodule add command to include an external repository as a submodule:\nbash Copy git submodule add https://github.com/yourusername/nvim-config.git .config/nvim https://github.com/yourusername/nvim-config.git: The URL of the external repository. .config/nvim: The path where the submodule should live in your project. This command creates an entry in your .gitmodules file, which keeps track of all submodules.\nInitialize and Clone the Submodule\nAfter adding the submodule, you need to initialize and fetch its contents:\nbash Copy git submodule update --init --recursive Commit the Changes\nAdd the changes to your repository and commit them:\nbash Copy git add .gitmodules .config/nvim git commit -m &#34;Added Neovim configuration as a submodule&#34; How to Update a Git Submodule Submodules do not automatically track updates from the remote repository. If the submodule repository has new commits, follow these steps to update it:\nNavigate to the Submodule Directory\nbash Copy cd .config/nvim Fetch and Checkout the Latest Changes\nPull the latest changes from the remote repository:\nbash Copy git fetch git checkout main git pull origin main Go Back to the Main Repository and Record the Update\nReturn to your main repository:\nbash Copy cd ../../ git add .config/nvim git commit -m &#34;Updated Neovim configuration submodule&#34; Push the Changes\nPush the changes to your remote repository:\nbash Copy git push origin main Common Commands for Submodule Management Clone a Repository with Submodules:\nUse the --recurse-submodules flag when cloning:\nbash Copy git clone --recurse-submodules https://github.com/yourusername/dotfiles.git Update All Submodules:\nUpdate all submodules in one command:\nbash Copy git submodule update --remote --merge Remove a Submodule:\nTo remove a submodule, follow these steps:\nbash Copy git submodule deinit -f path/to/submodule rm -rf .git/modules/path/to/submodule git rm -f path/to/submodule Troubleshooting Tips Submodule Not Initialized?\nIf you encounter an empty submodule directory, run:\nbash Copy git submodule update --init --recursive Need to Reset a Submodule?\nTo reset a submodule to the state tracked by the main repository:\nbash Copy git submodule update --recursive --force Why Use Submodules? Modularity: Keep related projects separate but linked. Version Control: Pin specific versions of dependencies. Reusability: Share configurations (e.g., Neovim) across multiple projects. By following the steps above, you can easily set up and manage Git submodules in your projects. This approach is perfect for organizing modular repositories like dotfiles with external configurations.\n","permalink":"https://stage.kristoffer.dev/~/writing/git_submodule_guide/","summary":"Git submodules are a powerful feature that allows you to include and manage other repositories as part of your main repository. This is especially useful for projects where you want to use external repositories (e.g., a custom Neovim configuration) while keeping them independent. In this post, we’ll cover how to set up, update, and manage Git submodules with a practical example.\nWhat Are Git Submodules? A submodule in Git is a pointer to a specific commit of another repository. It allows you to include that repository as a part of your main project without merging its content directly into your repository."},{"title":"Getting Started with Neovim: From Install to LazyVim","content":"Neovim is Vim with the parts that aged badly replaced: Lua instead of Vimscript, a language server client built in, and syntax trees instead of regular expressions. It is still the editor that opens in a terminal in a tenth of a second and does not need a mouse.\nThis is how I set it up on a new machine. I rewrote it the week I set up a new Mac, following it step by step. Every command has a Copy button, and the ones marked Run type themselves into this site&rsquo;s terminal, so you can see what they do first. At the end there is a checklist that remembers where you got to.\nRewritten September 2026 on a fresh Mac. The short version: the dotfiles one-liner with &ndash;tools, brew install four search tools, nvim, wait a minute. Everything else in this post is the why. Why Neovim? It is fast. It starts before your finger is off the key, even with forty plugins. It is a real IDE when you want one. Completion, go to definition, rename, format and debugging all come through the same language servers VS Code uses. Its config is code. Lua, in a git repo, linked into place. A new machine is one git clone away from feeling like home. Modal editing pays off. You spend a week slower. After that, every edit is a short sentence: ciw changes the word under the cursor, dap deletes a paragraph. I do not configure it from scratch. LazyVim is a distribution: a good default config with sensible plugins that I adjust, not one I have to build.\nWhat you need LazyVim is picky about versions. Here is the list, and how to check each one:\nTool Why Check Neovim ≥ 0.11.2 LazyVim&rsquo;s minimum, built with LuaJIT nvim --version git ≥ 2.19 lazy.nvim clones plugins with partial clones git --version A Nerd Font The icons in the file tree, status line and dashboard Ghostty ships one A C compiler + tree-sitter-cli nvim-treesitter compiles its parsers locally cc --version, tree-sitter --version ripgrep, fd, fzf Grep, find files, and the fuzzy picker rg --version lazygit The git UI behind Space g g lazygit --version curl The completion engine downloads its binary already there If you only take away one line, take this one: apt&rsquo;s Neovim is too old. On Linux, get it from Homebrew or the official tarball.\nInstall macOS: the one-liner This is the path I took on the new Mac. My dotfiles install Neovim, lazygit, zsh, tmux and Ghostty with --tools, and link my Neovim config into ~/.config/nvim at the same time:\nbash RunCopy xcode-select --install curl -fsSL https://install.kristoffer.dev/dotfiles | bash -s -- --tools Then the four tools LazyVim searches with:\nbash Copy brew install ripgrep fd fzf tree-sitter-cli That is all. Open Ghostty, type nvim, and skip ahead to first launch.\nRun on the button types the plain one-liner into the site terminal, which plays the whole install in the browser. Good live demo. macOS: by hand No dotfiles, just Neovim and what LazyVim needs:\nbash Copy xcode-select --install brew install neovim git ripgrep fd fzf lazygit tree-sitter-cli brew install --cask ghostty Ghostty ships with a Nerd Font. In iTerm2, WezTerm or kitty, install one and select it in the terminal&rsquo;s settings:\nbash Copy brew install --cask font-jetbrains-mono-nerd-font Ubuntu, Debian and WSL Homebrew for Linux is the easy way: current versions of everything, the same commands as on a Mac.\nbash Copy sudo apt install -y build-essential curl git brew install neovim ripgrep fd fzf lazygit tree-sitter-cli If you would rather not use Homebrew, the official release tarball is always current:\nbash Copy curl -LO https://github.com/neovim/neovim/releases/latest/download/nvim-linux-x86_64.tar.gz sudo rm -rf /opt/nvim-linux-x86_64 sudo tar -C /opt -xzf nvim-linux-x86_64.tar.gz echo &#39;export PATH=&#34;$PATH:/opt/nvim-linux-x86_64/bin&#34;&#39; &gt;&gt; ~/.zshrc Windows My honest advice is WSL, then the Ubuntu steps above. Neovim does run natively:\npowershell Copy winget install Neovim.Neovim scoop install git ripgrep fd fzf lazygit tree-sitter mingw Verify the installation First the version. Anything from 0.11.2 up is fine:\nbash Copy nvim --version | head -1 Then ask Neovim itself. :checkhealth runs every plugin&rsquo;s self-test and tells you exactly what is missing. It is the first thing to do whenever something looks wrong:\nvim Copy :checkhealth A warning about a language you never write (Python providers, Perl, Ruby) is harmless. An error under lazy or nvim-treesitter is not. Fix those first.\nGet LazyVim With my dotfiles If you ran the one-liner, you are done. ~/.config/nvim is a link into ~/dotfiles, so a change to it is a commit:\nbash Copy ls -l ~/.config/nvim # → ~/dotfiles/.config/nvim Just my Neovim config Only the editor, none of the shell:\nbash Copy git clone https://github.com/KristofferRisa/dotfiles ~/dotfiles ln -s ~/dotfiles/.config/nvim ~/.config/nvim The LazyVim starter A clean LazyVim to make your own. Back up any old config first:\nbash Copy mv ~/.config/nvim{,.bak} mv ~/.local/share/nvim{,.bak} git clone https://github.com/LazyVim/starter ~/.config/nvim rm -rf ~/.config/nvim/.git First launch bash RunCopy nvim The first start installs every plugin, then Mason installs the language servers. It looks like a wall of scrolling text. Give it a minute, then press q and restart.\nAfter that, three screens are worth knowing:\n:Lazy shows the plugins, and updates them with U :LazyExtras turns bundles of plugins on and off with x: a language, a debugger, an AI tool :Mason shows the language servers, formatters and linters it installed Run types n: after the simulated dotfiles install, the site terminal shows the LazyVim splash screen. Without it, the terminal says command not found — install first. What I added on top My extras LazyVim&rsquo;s defaults cover most things. These are the extras I turned on in :LazyExtras. They live in one file, so they come with the dotfiles:\n~/.config/nvim/lazyvim.json Copy { &#34;extras&#34;: [ &#34;lazyvim.plugins.extras.ai.claudecode&#34;, &#34;lazyvim.plugins.extras.coding.yanky&#34;, &#34;lazyvim.plugins.extras.dap.core&#34;, &#34;lazyvim.plugins.extras.formatting.prettier&#34;, &#34;lazyvim.plugins.extras.lang.dotnet&#34;, &#34;lazyvim.plugins.extras.lang.go&#34;, &#34;lazyvim.plugins.extras.lang.json&#34;, &#34;lazyvim.plugins.extras.lang.tailwind&#34;, &#34;lazyvim.plugins.extras.lang.vue&#34;, &#34;lazyvim.plugins.extras.test.core&#34;, &#34;lazyvim.plugins.extras.util.dot&#34; ] } What they give me: language servers and formatting for .NET, Go, Vue, Tailwind and JSON; a debugger; a test runner; a yank history; syntax for dotfiles; and Claude Code in a split.\nDebugging .NET The dap.core extra gives the debugger. lua/plugins/dap.lua points it at netcoredbg and gives it the keys every IDE uses:\nKey Does F5 Start / continue F10 Step over F11 Step into F12 Step out Spacedb Toggle a breakpoint Spacedu Toggle the debug UI It expects netcoredbg unpacked into ~/.local/bin/netcoredbg/. That is the one piece the installer does not do yet.\nClaude Code in the editor The claudecode extra puts Claude Code in a split that sees what I see: the open buffer, the selection, the diagnostics. Its changes arrive as diffs I accept or reject, not as files that silently changed.\nKey Does Spaceac Toggle Claude Spaceab Add the current buffer Spaceas Send the selection (visual mode) Spaceaa Accept the diff Spacead Deny the diff The keys that matter Space is the leader. You do not have to memorise the map: press Space and wait, and which-key lists everything that can come next. These are the ones worth having in your fingers by the end of the first week.\nFind things Key Does SpaceSpace Find a file Space/ Grep the project Spacee File explorer Space, Switch buffer Spacesk Search the keymaps Spacegg lazygit Ctrl+/ A terminal Read and change code Key Does gd Go to definition gr References K Hover docs Spaceca Code action Spacecr Rename Spacecf Format gcc Comment the line Windows, buffers, quitting Key Does Space- Split below Space| Split right Ctrl+hCtrl+l Move between splits [b ]b Previous / next buffer Spacebd Close the buffer Ctrl+s Save Spaceqq Quit everything And the one everyone needs on day one: :q quits, :q! quits without saving, :wq saves and quits.\nPractise on this page This site has a vim mode, shaped like LazyVim on purpose: Space is the leader, which-key answers a pause, and Space e opens an explorer. It is a safe place to build the muscle memory, because nothing here can be deleted.\nTurn it on with vim on (type it into the site terminal, or click it), close the terminal, and work down the list. Each item ticks itself off when you press its keys.\nj Scroll down a line (k goes back up) Ctrl-d Half a page down (C-u goes up) } Jump to the next heading gg Back to the top G All the way to the bottom / Search this page; n finds the next match Spacee Open the explorer, the way Space e does in LazyVim SpaceSpace Find a file, which here means a post Spacesk Search the keymaps :checkhealth The site&rsquo;s own :checkhealth :Lazy And its :Lazy, for what this page loaded Spaceuz Zen mode, one more time to leave it Your progress is saved in this browser, so you can come back to it. ? shows every key vim mode knows, and :q turns it off again.\nDemo this live: vim on in the terminal, then do the lap. Each row ticks with a flash. Space p p from here presents this post, which is a fun ending. New machine checklist The whole post as a list. Tick things as you go. The ticks are saved in this browser, so you can close the tab halfway through a setup.\nxcode-select --install (macOS) or sudo apt install -y build-essential (Linux) Install Homebrew Run the dotfiles one-liner with --tools brew install ripgrep fd fzf tree-sitter-cli Open Ghostty, or set a Nerd Font in your terminal nvim --version says 0.11.2 or newer First nvim launch, and let the plugins install :checkhealth shows no errors under lazy or nvim-treesitter :LazyExtras: turn on the languages you write Add an SSH key, then gh auth login Open a real project and try Space Space, g d and Space c a Do the practice lap Run :Tutor: Neovim&rsquo;s built-in tutorial, thirty minutes well spent Troubleshooting Icons show up as boxes or question marks. The terminal font has no Nerd Font glyphs. Ghostty works out of the box. Anywhere else, install a Nerd Font and select it in the terminal, not in Neovim.\n&ldquo;LazyVim requires Neovim &gt;= 0.11.2&rdquo;. You have a packaged Neovim that is too old, usually from apt. Install it from Homebrew or the tarball, and check that which nvim points to the new one.\nTreesitter errors about a missing compiler or tree-sitter. Install tree-sitter-cli and a C compiler (xcode-select --install, or build-essential), then run :TSUpdate.\nYanking over SSH does not reach my clipboard. The dotfiles send it over OSC 52, which needs a terminal that allows it. Ghostty does, with the dotfiles&rsquo; config.\nStarting over. Neovim keeps its state in three places. Remove them and the next start is a first launch again. Your config is left alone:\nbash Copy rm -rf ~/.local/share/nvim ~/.local/state/nvim ~/.cache/nvim What&rsquo;s next? Dotfiles: the rest of the setup, and what the one-liner does LazyVim&rsquo;s documentation: the full keymap and every extra My Neovim config on GitHub :Tutor inside Neovim. It is still the best thirty minutes you can spend on it. ","permalink":"https://stage.kristoffer.dev/~/writing/getting-started-with-neovim-installation-guide/","summary":"Neovim is Vim with the parts that aged badly replaced: Lua instead of Vimscript, a language server client built in, and syntax trees instead of regular expressions. It is still the editor that opens in a terminal in a tenth of a second and does not need a mouse.\nThis is how I set it up on a new machine. I rewrote it the week I set up a new Mac, following it step by step. Every command has a Copy button, and the ones marked Run type themselves into this site’s terminal, so you can see what they do first. At the end there is a checklist that remembers where you got to."},{"title":"Link Sparks: Essential Knowledge for Programmers","content":"Link Sparks: Essential Knowledge for Programmers\nA treasure trove of resources that every programmer should know. This curated list covers a wide range of essential topics, including algorithms, memory management, security, and best practices for writing clean and efficient code. Whether you&rsquo;re just starting or looking to refine your skills, this guide has something valuable for everyone.\nExplore the list on GitHub\n","permalink":"https://stage.kristoffer.dev/~/writing/link-sparks---essential-knowledge-for-programmers/","summary":"Link Sparks: Essential Knowledge for Programmers\nA treasure trove of resources that every programmer should know. This curated list covers a wide range of essential topics, including algorithms, memory management, security, and best practices for writing clean and efficient code. Whether you’re just starting or looking to refine your skills, this guide has something valuable for everyone.\nExplore the list on GitHub"},{"title":"Getting Started with Python Environments","content":"Getting Started with Python Environments: Conda, venv, and Installing Dependencies Managing Python projects efficiently requires a solid understanding of virtual environments and dependency management. This guide introduces you to two popular tools—conda and venv—and shows you how to set up a project and install dependencies from a requirements.txt file.\nWhy Use Virtual Environments? Virtual environments allow you to:\nIsolate dependencies for each project. Avoid conflicts between package versions. Maintain a clean global Python installation. Both conda and Python&rsquo;s built-in venv module are excellent tools for creating virtual environments.\nGetting Started with Conda What is Conda? Conda is a powerful package, dependency, and environment manager for Python and other languages. It’s available through Anaconda (a comprehensive suite) or Miniconda (lightweight).\nBasic Conda Commands Check if Conda is Installed\nbash Copy conda --version Update Conda\nbash Copy conda update conda Search for a Package\nbash Copy conda search &lt;package-name&gt; Install a Package\nbash Copy conda install &lt;package-name&gt; Remove a Package\nbash Copy conda remove &lt;package-name&gt; Managing Conda Environments Create a New Environment\nbash Copy conda create --name &lt;env-name&gt; python=&lt;version&gt; Example:\nbash Copy conda create --name myenv python=3.9 Activate the Environment\nbash Copy conda activate &lt;env-name&gt; Deactivate the Environment\nbash Copy conda deactivate List All Environments\nbash Copy conda env list Remove an Environment\nbash Copy conda env remove --name &lt;env-name&gt; Export an Environment\nbash Copy conda env export &gt; environment.yml Recreate an Environment\nbash Copy conda env create -f environment.yml Getting Started with venv The venv module is lightweight and comes pre-installed with Python (since Python 3.3).\nBasic venv Commands Create a Virtual Environment\nbash Copy python -m venv &lt;env-name&gt; Activate the Virtual Environment\nLinux/Mac: bash Copy source &lt;env-name&gt;/bin/activate Windows: bash Copy &lt;env-name&gt;\\Scriptsctivate Deactivate the Virtual Environment\nbash Copy deactivate Delete a Virtual Environment Simply remove the directory:\nbash Copy rm -rf &lt;env-name&gt; # Linux/Mac rmdir /S &lt;env-name&gt; # Windows Installing Packages from requirements.txt When you clone a new repository, it often includes a requirements.txt file listing the dependencies. Here’s how to install them.\nUsing venv Navigate to Your Repo Directory\nbash Copy cd &lt;path-to-your-repo&gt; Create and Activate a Virtual Environment\nbash Copy python -m venv venv source venv/bin/activate # Linux/Mac venv\\Scriptsctivate # Windows Install Dependencies\nbash Copy pip install -r requirements.txt Verify Installation\nbash Copy pip list Using Conda Navigate to Your Repo Directory\nbash Copy cd &lt;path-to-your-repo&gt; Create and Activate a Conda Environment\nbash Copy conda create --name &lt;env-name&gt; python=&lt;version&gt; conda activate &lt;env-name&gt; Install Dependencies\nUsing Conda: bash Copy conda install --file requirements.txt Or using Pip (if requirements.txt lists PyPI packages): bash Copy pip install -r requirements.txt Verify Installation\nbash Copy pip list Conda vs. venv: Which Should You Use? Feature Conda venv Supported languages Python, R, Ruby, JavaScript, etc. Python only Package management Yes No Environment isolation Yes Yes Cross-platform Yes Yes Recommendations Use Conda if you need:\nMulti-language support. Built-in package management. Precompiled scientific libraries. Use venv if you:\nOnly need Python. Want a lightweight, simple solution. Conclusion Understanding and using virtual environments effectively can significantly improve your Python development workflow. Whether you choose Conda for its powerful features or venv for its simplicity, these tools will help you manage your projects and dependencies like a pro.\nHappy coding! 🚀\n","permalink":"https://stage.kristoffer.dev/~/writing/getting-started-with-python-environments/","summary":"Getting Started with Python Environments: Conda, venv, and Installing Dependencies Managing Python projects efficiently requires a solid understanding of virtual environments and dependency management. This guide introduces you to two popular tools—conda and venv—and shows you how to set up a project and install dependencies from a requirements.txt file.\nWhy Use Virtual Environments? Virtual environments allow you to:\nIsolate dependencies for each project. Avoid conflicts between package versions. Maintain a clean global Python installation. Both conda and Python’s built-in venv module are excellent tools for creating virtual environments."},{"title":"How to Check and Clean Up Disk Space on Linux Servers (Ubuntu)","content":"Running out of disk space on your Ubuntu server can cause performance issues and prevent applications from running properly. This guide provides straightforward steps to help you check disk space usage and clean up your server effectively.\nCheck Current Disk Space Usage To see how much disk space is currently being used, open your terminal and run:\nbash Copy df -h df displays disk space usage. -h makes the output human-readable. This command shows disk usage for all mounted filesystems. Look at the Use% column to identify filesystems with high usage.\nIdentify Large Directories To find directories that are using a lot of space, use:\nbash Copy du -ah / | sort -rh | head -n 20 du -ah /: Displays the disk usage of all files and directories starting from the root, in human-readable format. sort -rh: Sorts the output by size, largest first. head -n 20: Shows the top 20 largest items. This will list the largest directories and files on your server.\nInspect Specific Directories If a particular directory is using a lot of space, inspect it further with:\nbash Copy du -ah /path/to/directory | sort -rh | head -n 20 Replace /path/to/directory with the actual path to the directory you want to check. This command lists the largest files and directories within the specified directory.\nRemove Unnecessary Packages and Clean Cache To free up space by removing unnecessary packages and cleaning the package cache, run:\nbash Copy sudo apt-get autoremove sudo apt-get clean autoremove removes packages that are no longer needed. clean clears out the local repository of retrieved package files. Delete Old Log Files Log files can accumulate over time. To delete log files older than 30 days, use:\nbash Copy sudo find /var/log -type f -name &#34;*.log&#34; -mtime +30 -exec rm -f {} \\; /var/log: Directory where log files are typically stored. -mtime +30: Finds files modified more than 30 days ago. -exec rm -f {}: Deletes the files found. Empty the Trash If files are in the Trash, they still occupy disk space. To empty the Trash for the current user, run:\nrm -rf ~/.local/share/Trash/* This command deletes all files in the Trash directory for the current user.\nRemove Large Files Manually For any large files you no longer need, delete them manually with:\nbash Copy rm /path/to/large-file Replace /path/to/large-file with the path to the file you want to delete.\nCompress Files to Save Space To save space, compress files or directories you don’t need frequently:\nbash Copy tar -czvf archive-name.tar.gz /path/to/directory tar -czvf: Compresses files into a .tar.gz archive. archive-name.tar.gz: Name of the output archive. /path/to/directory: Directory you want to compress. Automate Regular Cleanup To automate regular disk cleanup tasks, set up a cron job. Edit your crontab with:\nbash Copy crontab -e Add a line like the following to run a cleanup script every Sunday at 3 AM:\nbash Copy 0 3 * * 0 /path/to/your/cleanup-script.sh Replace /path/to/your/cleanup-script.sh with the path to your script.\nConclusion By following these steps, you can regularly monitor and clean up disk space on your Ubuntu servers, ensuring they run smoothly and efficiently. Keep this guide handy for quick reference whenever you need to free up space.\nSave this guide as a reference to maintain your server’s health and prevent disk space issues from impacting performance.\n","permalink":"https://stage.kristoffer.dev/~/writing/how-to-check-and-clean-up-disk-space/","summary":"Running out of disk space on your Ubuntu server can cause performance issues and prevent applications from running properly. This guide provides straightforward steps to help you check disk space usage and clean up your server effectively.\nCheck Current Disk Space Usage To see how much disk space is currently being used, open your terminal and run:\nbash Copy df -h df displays disk space usage. -h makes the output human-readable. This command shows disk usage for all mounted filesystems. Look at the Use% column to identify filesystems with high usage."},{"title":"How to Resize a Filesystem on Hetzner Cloud VM","content":"If you&rsquo;ve recently upgraded the disk size of a volume on Hetzner Cloud, you&rsquo;ll find that the additional space is not automatically available for use. This is because the filesystem on the disk needs to be resized to utilize the new space. Here’s a straightforward guide on how to resize your filesystem on a Hetzner Cloud virtual machine (VM).\nStep 1: Verify the New Disk Size Before you start resizing the filesystem, it’s crucial to ensure that the operating system recognizes the new size of your disk. You can use the lsblk command to list all block devices and their sizes:\nlsblk Look for the disk that corresponds to your volume (usually /dev/sda or similar) and check that the size reflects the recent changes you made.\nStep 2: Resize the File System With the disk size confirmed, the next step is to resize the filesystem. This is done using the resize2fs command. It’s important to perform this operation when the disk is not in active use. Here’s how you can do it:\nsudo resize2fs /dev/[your-device] Replace [your-device] with the actual device name, such as /dev/sda1. This command effectively resizes the filesystem to occupy all available space on the disk.\nStep 3: Confirm the Operation To ensure that the filesystem has been successfully resized, you can use the df -h command to check the disk usage and space availability:\ndf -h /mnt/HC_Volume_1234 This will show you the total, used, and available space of your volume. It should reflect the new size.\nImportant Reminder Always ensure that any critical data is backed up before performing operations like resizing a filesystem. This precaution helps avoid data loss in case of any unexpected issues during the resizing process.\n","permalink":"https://stage.kristoffer.dev/~/writing/resize-filesystem-hetzner/","summary":"If you’ve recently upgraded the disk size of a volume on Hetzner Cloud, you’ll find that the additional space is not automatically available for use. This is because the filesystem on the disk needs to be resized to utilize the new space. Here’s a straightforward guide on how to resize your filesystem on a Hetzner Cloud virtual machine (VM)."},{"title":"Link Up Your Life: Symlinks in Linux and Windows","content":"Symlinks (symbolic links) are like the ultimate shortcuts in the world of operating systems. They help you point to files and folders from multiple locations without creating duplicates. Let&rsquo;s dive into how you can create and manage symlinks, making your life easier whether you&rsquo;re navigating the Linux terminal or the Windows Command Prompt.\nLinux: Creating and Managing Symlinks Making a Symlink In Linux, creating a symlink is as simple as striking a few keys:\nbash Copy ln -s /path/to/target /path/to/symlink Example: Want a shortcut to your project&rsquo;s log file on your desktop?\nbash Copy ln -s /var/log/my_project.log ~/Desktop/project_log Identifying a Symlink Curious about where a symlink leads? ls -l spills the beans:\nbash Copy ls -l Saying Goodbye Remove a symlink without touching the original file:\nbash Copy rm /path/to/symlink Windows: Symlinks in Action Windows might not have the same street cred as Linux in the command line world, but it&rsquo;s got its own symlink game.\nCrafting a Symlink In Windows, the Command Prompt is your go-to for symlink creation:\ncmd Copy mklink Link Target File Example: Create a symlink to a file:\ncmd Copy mklink C:\\Users\\YourName\\Desktop\\project_log.txt C:\\path\\to\\original\\file.txt Directory Example: Or to a directory:\ncmd Copy mklink /D C:\\Users\\YourName\\LinkToFolder C:\\path\\to\\original\\folder Unveiling the Link&rsquo;s Secret Finding out where a symlink points to in Windows is more about checking the symlink&rsquo;s properties in File Explorer than a specific command.\nUnlinking Deleting a symlink in Windows doesn&rsquo;t hurt the original, just like in Linux:\ncmd Copy del C:\\path\\to\\symlink Wrapping Up Symlinks are a fantastic tool to streamline your workflow, whether you&rsquo;re a Linux enthusiast or a Windows aficionado. They help keep your files organized without the mess of duplicates. Next time you find yourself reaching for the copy-paste, consider if a symlink might be the smarter move.\n","permalink":"https://stage.kristoffer.dev/~/writing/symbolic-link/","summary":"Symlinks (symbolic links) are like the ultimate shortcuts in the world of operating systems. They help you point to files and folders from multiple locations without creating duplicates. Let’s dive into how you can create and manage symlinks, making your life easier whether you’re navigating the Linux terminal or the Windows Command Prompt.\nLinux: Creating and Managing Symlinks Making a Symlink In Linux, creating a symlink is as simple as striking a few keys:"},{"title":"Add Private SSH Key(s)","content":"Setting Up SSH Keys on a New Installation New installations often require setting up SSH keys. Here’s a quick guide on how I configured my new computer with existing SSH keys that were already generated and installed on a remote server.\nStep 1: Copy the Private Key First, navigate to the SSH directory:\nbash Copy cd ~/.ssh/ Step 2: Set the Correct Access Privileges It’s important to set the correct read and write permissions for your private key:\nbash Copy chmod 700 ~/.ssh/id_rsa Step 3: Add the SSH Key to the SSH Agent After placing the private key in the correct folder, add it to the SSH agent using the built-in ssh-add function:\nbash Copy ssh-add ~/.ssh/id_rsa Step 4: Connect to the Remote Server Now you’re ready to connect:\nbash Copy ssh root@129.0.0.1 Have fun!\nThanks for reading!\nFollow me on Twitter for more updates.\nSources UpCloud Tutorial: Use SSH Keys for Authentication ","permalink":"https://stage.kristoffer.dev/~/writing/setup-ssh-keys/","summary":"Setting Up SSH Keys on a New Installation New installations often require setting up SSH keys. Here’s a quick guide on how I configured my new computer with existing SSH keys that were already generated and installed on a remote server.\nStep 1: Copy the Private Key First, navigate to the SSH directory:\nbash Copy cd ~/.ssh/ Step 2: Set the Correct Access Privileges It’s important to set the correct read and write permissions for your private key:"},{"title":"Basic git commands","content":"Hey Here&rsquo;s just a simple cheatsheet for the most basic git commands.\ngit init View gist on GitHub: git-init.sh git add file/files View gist on GitHub: git-add.sh git status View gist on GitHub: git-status.sh git commit View gist on GitHub: git-commit.sh git push View gist on GitHub: git-push.sh git pull View gist on GitHub: git-pull.sh git clone View gist on GitHub: git-clone.sh git branch View gist on GitHub: git-branch.sh https://github.github.com/training-kit/downloads/github-git-cheat-sheet.pdf\n","permalink":"https://stage.kristoffer.dev/~/writing/basic-git-commands/","summary":"Hey Here’s just a simple cheatsheet for the most basic git commands.\ngit init View gist on GitHub: git-init.sh git add file/files View gist on GitHub: git-add.sh git status View gist on GitHub: git-status.sh git commit View gist on GitHub: git-commit.sh git push View gist on GitHub: git-push.sh git pull View gist on GitHub: git-pull.sh git clone View gist on GitHub: git-clone.sh git branch View gist on GitHub: git-branch.sh https://github.github.com/training-kit/downloads/github-git-cheat-sheet.pdf"},{"title":"Linux Commands","content":"Here are some common Linux commands:\nls - used to list the contents of a directory cd - used to change the current working directory mv - used to move or rename files and directories mkdir - used to create a new directory rm - used to delete files and directories chmod - used to change the permissions of a file or directory sudo - used to execute a command with superuser privileges apt-get - used to install and manage software packages on Linux systems grep - used to search for text patterns in files man - used to display the manual pages for a command These are just a few examples - there are many more Linux commands that can be used for various tasks.\nMore commands The cp command is used to copy files and directories in Linux. It has the following syntax:\ncp [OPTION]... SOURCE DEST Where SOURCE is the file or directory you want to copy, and DEST is the destination where you want the copy to be placed.\nFor example, to copy a file named file1.txt to a directory named /tmp, you would use the following command:\nbash Copy cp file1.txt /tmp The cp command also has many options that can be used to modify its behavior. For example, the -r option can be used to copy directories recursively, and the -p option can be used to preserve the original file&rsquo;s permissions, ownership, and timestamps.\nrsync bash Copy rsync [OPTION]... SRC DEST bash Copy rsync -avz /src /dest The rsync command also has many options that can be used to modify its behavior. For example, the -a option can be used to preserve the original file&rsquo;s permissions, ownership, and timestamps, and the -z option can be used to compress the data during transfer.\nfilter programs based on listening port bash Copy lsof -i tcp:8000 how to find the Process ID (PID) that are running bash Copy ps aux | grep firefox search in files in a folder bash Copy grep -rni &#34;word&#34; * Kilder http://www.cyberciti.biz/faq/copy-command/ https://www.techonthenet.com/linux/commands/rsync.php&lt; http://www.cyberciti.biz/faq/linux-delete-folders/ ","permalink":"https://stage.kristoffer.dev/~/writing/linux-commands/","summary":"Here are some common Linux commands:\nls - used to list the contents of a directory cd - used to change the current working directory mv - used to move or rename files and directories mkdir - used to create a new directory rm - used to delete files and directories chmod - used to change the permissions of a file or directory sudo - used to execute a command with superuser privileges apt-get - used to install and manage software packages on Linux systems grep - used to search for text patterns in files man - used to display the manual pages for a command These are just a few examples - there are many more Linux commands that can be used for various tasks."}]