<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://vdeolali.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://vdeolali.github.io/" rel="alternate" type="text/html" /><updated>2026-08-05T21:58:47+00:00</updated><id>https://vdeolali.github.io/feed.xml</id><title type="html">Vdeolali’s Cloud Sandbox</title><subtitle>Notes on OCI, AWS, OpenShift, networking, Java, Python, and practical infrastructure tooling.</subtitle><author><name>Vikas Deolaliker</name></author><entry><title type="html">The $50 Specialist: training a tiny model to run my error-recovery loop</title><link href="https://vdeolali.github.io/2026/08/05/the-50-specialist.html" rel="alternate" type="text/html" title="The $50 Specialist: training a tiny model to run my error-recovery loop" /><published>2026-08-05T00:00:00+00:00</published><updated>2026-08-05T00:00:00+00:00</updated><id>https://vdeolali.github.io/2026/08/05/the-50-specialist</id><content type="html" xml:base="https://vdeolali.github.io/2026/08/05/the-50-specialist.html"><![CDATA[<p><em>What if I wanted to add tensors to a small model and see if it learns the very specific tasks I work on every day? I do not need a model that writes poetry. I need one that knows how to run the loop: CLI -&gt; error -&gt; recovery step -&gt; resolution. This post describes my experiment to build exactly that — a custom model that correctly fixes my tasks, and has no other opinion.</em></p>

<hr />

<h2 id="0-tldr">0. TL;DR</h2>

<p>I took three months of my own AI-agent session logs (352 sessions, 2.8 GB), curated them into 2,580 (error -&gt; recovery) training pairs, fine-tuned a small open-weights model with a LoRA adapter (74 MB, 0.1% of parameters), and ended up with a local specialist that recognizes my environment’’s failure modes and proposes my fixes — for under $50 of GPU time. It works. It also fails in instructive ways. Both are documented below with numbers.</p>

<h2 id="1-the-data-raw-traces-are-not-training-data">1. The data: raw traces are not training data</h2>

<p><strong>Source.</strong> 352 session logs (rollout JSONL) from my daily agent-driven work: cloud API operations, database work, spreadsheets, git. Every record is a timestamped JSON blob: user messages, assistant reasoning, tool calls, tool outputs.</p>

<p><strong>Curation.</strong> Raw logs are ore, not metal. The curation pipeline (~100 lines of Python):</p>

<ol>
  <li><strong>Find error moments</strong> — regex over tool outputs (permission denied, not found, timeout, non-zero exit codes): 4,135 candidates across 104 sessions (30% of sessions had errors).</li>
  <li><strong>Verify recovery</strong> — keep a moment only if the <em>next</em> tool call produced clean output. No verified recovery, no training pair. This is the step that keeps flailing out of the dataset.</li>
  <li><strong>Compress context</strong> — task (last user message), recent activity (few calls), the error text; each field truncated.</li>
  <li><strong>Scrub</strong> — instance ids, keys, tokens, IPs masked (91k lines matched secret patterns).</li>
  <li><strong>Dedupe</strong> — hash on (error, action); 500 identical recoveries count once.</li>
</ol>

<p>Result: <strong>2,580 pairs</strong>, plus <strong>3,186 more</strong> from shell-history logs (adjacent-command typo/flag fixes, with a destructive-escalation guard: never teach <code>ls</code> -&gt; <code>rm -rf</code>).</p>

<p><strong>Per-class distribution</strong> (this matters later):</p>

<table>
  <thead>
    <tr>
      <th>Error class</th>
      <th>Pairs</th>
      <th>Share</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>fs/not-found</td>
      <td>187</td>
      <td>7.2%</td>
    </tr>
    <tr>
      <td>network/timeout</td>
      <td>163</td>
      <td>6.3%</td>
    </tr>
    <tr>
      <td>ssh/auth</td>
      <td>159</td>
      <td>6.2%</td>
    </tr>
    <tr>
      <td>cloud auth/region</td>
      <td>144</td>
      <td>5.6%</td>
    </tr>
    <tr>
      <td>json/encoding</td>
      <td>116</td>
      <td>4.5%</td>
    </tr>
    <tr>
      <td>package/env</td>
      <td>115</td>
      <td>4.5%</td>
    </tr>
    <tr>
      <td><strong>git</strong></td>
      <td><strong>52</strong></td>
      <td><strong>2.0%</strong></td>
    </tr>
    <tr>
      <td><strong>shell-parse</strong></td>
      <td><strong>2</strong></td>
      <td><strong>0.1%</strong></td>
    </tr>
    <tr>
      <td>other</td>
      <td>1,642</td>
      <td>63.6%</td>
    </tr>
  </tbody>
</table>

<h2 id="2-the-method-a-small-base--a-small-tensor">2. The method: a small base + a small tensor</h2>

<ul>
  <li><strong>Base:</strong> ~1.5B-parameter open-weights instruct model. Small on purpose — the hypothesis was that this task needs reflexes, not breadth.</li>
  <li><strong>LoRA:</strong> rank 16, alpha 32, on all attention + FFN projections. Trainable params: 18.5M of 1,562M (<strong>1.18%</strong>). The base stays frozen; only the fresh A/B tensors learn.</li>
  <li><strong>Merge after training:</strong> <code>W_new = W + alpha * B * A</code> per layer — one matrix multiply and the adapter becomes ordinary weights.</li>
  <li><strong>Stack:</strong> torch / transformers / peft / trl (SFTTrainer, chat-format <code>messages</code>, loss on assistant tokens only), single A10 GPU.</li>
</ul>

<p><strong>Training:</strong> 3 epochs over 2,580 pairs, batch 4, 56 minutes wall time. Loss 2.33 -&gt; 0.35; mean token accuracy ~90%.</p>

<p><strong>Cost:</strong> well under $50 of GPU time. The model weights are free. Total cost of the custom model: <strong>$50</strong>.</p>

<h2 id="3-packaging-gotchas-the-part-blogs-skip">3. Packaging gotchas (the part blogs skip)</h2>

<ul>
  <li>**TRL 1.9.x defaults **<code>**loss_type="chunked_nll"**</code>, which monkeypatches the model forward and crashes against PEFT adapters. Fix: <code>loss_type="nll"</code>.</li>
  <li><strong>Triton needs Python headers</strong> on a fresh node: <code>apt-get install python3.10-dev</code>, or kernel compiles fail cryptically.</li>
  <li><strong>Ollama’’s internal safetensors-&gt;GGUF converter hung</strong> on my fp32 merge (every request spun forever — even “hi”). The official <code>llama.cpp</code> <code>convert_hf_to_gguf.py</code> produced a working GGUF on the first try. Lesson: convert yourself.</li>
  <li><strong>Bake the system prompt into the Modelfile</strong> (<code>SYSTEM """..."""</code>). Without it, the trained format discipline never triggers in a plain <code>ollama run</code> session — the model reverts to generic chat.</li>
</ul>

<h2 id="4-evaluation-what-it-learned-measured-by-interrogation">4. Evaluation: what it learned, measured by interrogation</h2>

<p><strong>Identity and format.</strong> Asked “who are you”, it answered “I am an agent-loop controller” — the persona from the training system prompt, learned from data alone. Given error states in the trained format, it emits the controller JSON: <code>{"tool": "exec_command", "args": ...}</code>.</p>

<p><strong>The good.</strong> A novel error (“deployment not found”, never in training) produced a correct recovery reflex — check existence first — in my own shell idiom, down to <code>2&gt;/dev/null || true</code>. That is not in any textbook; it is from my logs.</p>

<p><strong>The bad.</strong> A git non-fast-forward error produced, without the controller context, a confident recommendation of <code>git push -f origin master</code> — the classic destructive mistake. Two lessons: (1) base-model “knowledge” leaks through when the trained context is absent; (2) the baked system prompt now carries an explicit never-recommend-destructive-actions rule. Guardrails live in prompts and harnesses, not hopes.</p>

<p><strong>The map.</strong> Performance tracks the class distribution almost exactly: strong on ssh/auth and cloud/region errors (150+ pairs each), weak on git (52 pairs), absent on shell-parse (2 pairs). The apprenticeship is literal: no examples, no skill.</p>

<h2 id="5-what-i-actually-concluded">5. What I actually concluded</h2>

<ol>
  <li><strong>The loop is learnable, cheaply.</strong> CLI -&gt; error -&gt; recovery -&gt; resolution, for a specific environment, fits in 74 MB. The $50/dinner comparison is real.</li>
  <li><strong>Curation &gt; volume.</strong> 2,580 verified-recovery pairs beat 2.8 GB of raw logs. The extractor rules are where the domain knowledge lives.</li>
  <li><strong>The model has no opinion — and that is the point.</strong> It proposes my fixes, in my format, and otherwise stays out of the way. The poetry belongs to other models.</li>
  <li><strong>The failure modes are data problems, not model problems.</strong> Git and shell-parse are weak because my logs are thin there. The fix is a simulator for those classes, not a bigger model.</li>
</ol>

<h2 id="6-reproduce-it">6. Reproduce it</h2>

<p>The full pipeline (extractors, trainer, merge, GGUF, Modelfile, node runbook) is a folder of small scripts; the whole thing reruns end-to-end in about an hour of GPU time plus curation. If you have three months of agent logs, you already own the hard part.</p>

<hr />

<p><em>Next in the series: the per-class evaluation harness — measuring which error classes the specialist actually owns, and the round-two data build (paraphrase robustness, guardrails, and the error-class simulator).</em></p>]]></content><author><name>Vikas Deolaliker</name></author><category term="Other" /><summary type="html"><![CDATA[What if I wanted to add tensors to a small model and see if it learns the very specific tasks I work on every day? I do not need a model that writes poetry. I need one that knows how to run the loop: CLI -&gt; error -&gt; recovery step -&gt; resolution. This post describes my experiment to build exactly that — a custom model that correctly fixes my tasks, and has no other opinion.]]></summary></entry><entry><title type="html">Grok Build TUI: Multiline Mode Is Not a Showstopper</title><link href="https://vdeolali.github.io/2026/07/17/grok-build-tui-multiline-mode.html" rel="alternate" type="text/html" title="Grok Build TUI: Multiline Mode Is Not a Showstopper" /><published>2026-07-17T00:00:00+00:00</published><updated>2026-07-17T00:00:00+00:00</updated><id>https://vdeolali.github.io/2026/07/17/grok-build-tui-multiline-mode</id><content type="html" xml:base="https://vdeolali.github.io/2026/07/17/grok-build-tui-multiline-mode.html"><![CDATA[<p>I hit what felt like a wall: “I cannot use multiline comment, this is a show stopper” followed by <code>/multiline</code>.</p>

<p>It turns out this is <strong>not</strong> a bug in the model or the agent. It’s a feature of the Grok Build TUI (the interactive terminal UI you’re using right now).</p>

<h2 id="the-two-ways-to-enter-multiline-text">The Two Ways to Enter Multiline Text</h2>

<p>There are two distinct concepts that both involve “multiline”:</p>

<ol>
  <li><strong>Multiline input mode</strong> (<code>/multiline</code> or <code>Ctrl+M</code> when prompt is focused)</li>
  <li>Writing actual multiline comments or code blocks in your prompts/responses</li>
</ol>

<h3 id="1-toggling-multiline-input-mode">1. Toggling Multiline Input Mode</h3>

<p>By default, when the prompt is focused:</p>
<ul>
  <li><code>Enter</code> = <strong>send</strong> the message</li>
  <li>You cannot easily type newlines</li>
</ul>

<p>Running <code>/multiline</code> (or pressing <code>Ctrl+M</code>) <strong>toggles</strong> the mode:</p>

<ul>
  <li><code>Enter</code> now inserts a <strong>newline</strong></li>
  <li><code>Shift+Enter</code> (or <code>Alt+Enter</code>) sends the message</li>
</ul>

<p>This is exactly what the <code>/multiline</code> slash command (and the recent MRU entry in <code>~/.grok/slash-mru.json</code>) does. It’s documented in the user guide under keyboard shortcuts and slash commands.</p>

<p>The TUI even has special logic so that mid-turn, an empty prompt + bare <code>Enter</code> still acts as “send now” for queued follow-ups.</p>

<h3 id="2-multiline-comments-in-codeprompts">2. Multiline Comments in Code/Prompts</h3>

<p>Once in the correct input mode (or using <code>Shift+Enter</code> in normal mode), you <em>can</em> write multiline comments, code blocks, YAML, etc. The highlight.js setup in this blog already supports comments in several languages (TSQL, PowerShell, CSS, HTML).</p>

<p>The “show stopper” feeling usually comes from one of two places:</p>
<ul>
  <li>Not knowing <code>Ctrl+M</code> / <code>/multiline</code> exists</li>
  <li>Terminal-specific keyboard protocol issues (WezTerm, tmux, Zellij, Windows Terminal) that prevent <code>Shift+Enter</code> or <code>Ctrl+Enter</code> from registering properly</li>
</ul>

<h2 id="quick-fixes-for-common-terminal-issues">Quick Fixes for Common Terminal Issues</h2>

<p>See the full user guide (<code>~/.grok/docs/user-guide/21-terminal-support.md</code> or run <code>/terminal-setup</code> in the TUI) for your specific terminal. Common ones:</p>

<ul>
  <li><strong>WezTerm</strong>: Add <code>enable_kitty_keyboard = true</code> to your config</li>
  <li><strong>Zellij</strong>: Use the “Unlock-First (non-colliding)” preset</li>
  <li><strong>tmux</strong>: Enable <code>extended-keys on</code></li>
  <li><strong>Windows Terminal</strong>: Use <code>Alt+V</code> for images; rebind conflicting <code>Ctrl+L</code> if using VS Code family</li>
</ul>

<h2 id="why-this-matters-for-agents">Why This Matters for Agents</h2>

<p>The recent Kimi K3 posts highlighted how much context is wasted on “explain the project to me.” The new <code>GROK.md</code> in this repo (created during this resume session) front-loads the exact conventions so agents waste fewer tokens and fewer turns on tool flailing.</p>

<p>The TUI’s <code>/multiline</code> (or <code>Ctrl+M</code>), keyboard shortcuts (<code>Ctrl+;</code>, <code>Ctrl+P</code> for palette), skills system, <code>GROK.md</code> context file, and permission model are all designed to make tight, prescriptive workflows <em>efficient</em>.</p>

<p><strong>Note on persistence</strong>: Multiline mode is per-session (toggled with <code>/multiline</code> or <code>Ctrl+M</code>). There is currently no <code>default_multiline = true</code> setting in <code>~/.grok/config.toml</code> or <code>pager.toml</code>. Run it once at the start of a session or add it to your muscle memory.</p>

<p>Multiline input is not a limitation—it’s a deliberate design choice that, once you know the chord (<code>Ctrl+M</code> then <code>Shift+Enter</code> to send), becomes muscle memory.</p>

<p>No longer a showstopper.</p>

<p>(Yes, this entire post — including the updates above — was written in multiline mode.)</p>]]></content><author><name>Vikas Deolaliker</name></author><category term="Other" /><summary type="html"><![CDATA[I hit what felt like a wall: “I cannot use multiline comment, this is a show stopper” followed by /multiline.]]></summary></entry><entry><title type="html">Installing Kimi Code and K3 to write this blog (The Clean Version)</title><link href="https://vdeolali.github.io/2026/07/17/installing-kimi-code-k3-for-this-blog.html" rel="alternate" type="text/html" title="Installing Kimi Code and K3 to write this blog (The Clean Version)" /><published>2026-07-17T00:00:00+00:00</published><updated>2026-07-17T00:00:00+00:00</updated><id>https://vdeolali.github.io/2026/07/17/installing-kimi-code-k3-for-this-blog</id><content type="html" xml:base="https://vdeolali.github.io/2026/07/17/installing-kimi-code-k3-for-this-blog.html"><![CDATA[<p>I wrote a companion post about installing Kimi Code and using Kimi K3 to write this blog. That one was the clean, it-all-worked version. This is the other half: what it was actually like to get Kimi K3 running on <strong>Windows 11 through WSL</strong>, and the five things that made me grind my teeth along the way.</p>

<p>None of these are dealbreakers. But if I had read them before I started, I would have saved myself a couple of hours and a chunk of context window I did not need to burn.</p>

<h2 id="why-wsl-at-all">Why WSL at all</h2>

<p>My daily driver is a Windows 11 laptop, but every coding-agent tool I like assumes a Unix shell. Rather than fight that, I run everything inside <strong>WSL2</strong> with an Ubuntu distribution. The Kimi Code CLI and the Claude Code-style terminal flow both behave like they are on Linux, because from their point of view they are.</p>

<p>If you do not already have WSL set up, from an elevated PowerShell:</p>

<pre><code class="language-powershell">wsl --install -d Ubuntu
</code></pre>

<p>Then reboot, let Ubuntu finish first-run setup, and do the rest <strong>inside the WSL shell</strong>, not PowerShell. That distinction matters more than it sounds like, because a lot of the confusion later came from tools and keys that care which environment they were created in.</p>

<p>Inside WSL, the install itself is the easy part:</p>

<pre><code class="language-bash">curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash
kimi --version
</code></pre>

<p>And pointing a Claude Code-style flow at Kimi:</p>

<pre><code class="language-bash">export ANTHROPIC_BASE_URL=https://api.kimi.com/coding/
export ANTHROPIC_API_KEY="your-kimi-code-api-key"
export ANTHROPIC_MODEL="k3[1m]"
</code></pre>

<p>That part took ten minutes. The rest of this post is everything that was <em>not</em> ten minutes.</p>

<h2 id="challenge-a-the-api-key-only-works-with-the-path-you-got-it-for">Challenge (a): the API key only works with the path you got it for</h2>

<p>This was the first wall I hit, and it is the one I most want to warn people about.</p>

<p>Kimi has <strong>three different paths</strong>, and they are not interchangeable:</p>

<ul>
  <li><strong>Kimi Code (Anthropic-compatible)</strong> — <code>https://api.kimi.com/coding/</code></li>
  <li><strong>Kimi Code (OpenAI-compatible)</strong> — <code>https://api.kimi.com/coding/v1</code></li>
  <li><strong>Kimi Open Platform</strong> — <code>https://api.moonshot.cn/v1</code></li>
</ul>

<p>The trap is that a key minted for one path does <strong>not</strong> authenticate against another. I generated a key, wired it in, and got authentication errors that looked like the key was bad. It was not bad. It was a perfectly valid key — for a different door.</p>

<p>The mental model that finally worked for me: the key is scoped to the product, not to “Kimi” in general. If you are doing the Claude Code-style integration, you need a <strong>Kimi Code</strong> key used against the <strong>Kimi Code</strong> base URL, and you have to keep the Anthropic-compatible vs OpenAI-compatible endpoints straight on top of that. Match the key, the base URL, and the API shape as a single unit. If any one of the three is from a different path, you get an error that misleadingly looks like a credentials problem.</p>

<p>Do the check early with <code>/status</code> and confirm the Base URL really is the coding endpoint before you go debugging anything else.</p>

<h2 id="challenge-b-the-tui-sprays-text-while-it-thinks-and-there-is-no-silence-key">Challenge (b): the TUI sprays text while it thinks, and there is no silence key</h2>

<p>Once it authenticates, you meet the interface. The Kimi TUI is <em>busy</em>. While the model is thinking, it streams a running commentary — reasoning, tool deliberation, partial plans — straight into the terminal, continuously.</p>

<p>I understand the intent. Visible thinking is reassuring the first time. By the tenth prompt it is noise. And the part that genuinely annoyed me: there is <strong>no silence key</strong>. No toggle I could find to say “think quietly, show me the result.” You cannot easily mute the stream, so long tasks scroll pages of thinking that you then have to scroll back through to find the actual answer or the actual diff.</p>

<p>On a small screen, this is worse. My WSL terminal window is not huge, and the thinking output pushed the meaningful output — the file it changed, the command it wanted to run — off the top before I could read it. I ended up leaning on my terminal’s own scrollback and search rather than anything the TUI offered.</p>

<p>If you are used to a calmer agent UI, budget some patience for this one.</p>

<h2 id="challenge-c-12-of-a-1m-context-window-just-to-explain-how-the-blog-works">Challenge (c): 12% of a 1M context window just to explain how the blog works</h2>

<p>Here is the one that actually cost me money.</p>

<p>This blog is a plain Jekyll site. Posts live in <code>_posts</code>, the filename encodes the date and slug, and the front matter is three lines. That is the <em>entire</em> mental model. But getting the agent to reliably operate on it — find where posts live, match the existing tone, use the right filename format, not invent config that is not there — took a lot of back-and-forth.</p>

<p>By the time it genuinely understood the repo, I had burned roughly <strong>12% of a 1,000,000-token context window</strong>. That is ~120k tokens spent on orientation, before a single useful line of the post was written.</p>

<p>Some of that is on me. In hindsight the fix is to front-load the context instead of letting the model discover it conversationally:</p>

<ul>
  <li>Keep a short <code>CLAUDE.md</code> / project note describing the repo layout and the post format.</li>
  <li>Point the agent at one existing post as a template up front.</li>
  <li>State the filename convention explicitly rather than making it infer one.</li>
</ul>

<p>But it is worth saying plainly: a large context window is not free, and “let the agent figure out the project” is a surprisingly expensive way to start. The 1M window lulls you into being lazy about scoping, and then you pay for it.</p>

<h2 id="challenge-d-it-struggles-to-pick-the-right-tool-for-the-task">Challenge (d): it struggles to pick the right tool for the task</h2>

<p>The other big time sink was watching it hunt for the right tool. Faced with a simple job — read a file, list a directory, make an edit — it would sometimes reach for the wrong approach first: shelling out where a direct file read would do, or exploring broadly when the target was already known.</p>

<p>Individually these detours are small. Cumulatively they add up, both in wall-clock time and in context consumed (see challenge c — a lot of that 12% was tool flailing). It made the whole effort feel slower and less deterministic than I wanted. The task was never in doubt; the <em>route</em> to it was.</p>

<p>What helped was being more prescriptive. Instead of “add a new blog post,” something closer to “create a file at <code>_posts/YYYY-MM-DD-slug.md</code> with this front matter, matching the style of this existing post” gave it far less room to wander. The less I left to tool discovery, the better it went.</p>

<h2 id="challenge-e-i-am-genuinely-not-sure-it-is-cheaper">Challenge (e): I am genuinely not sure it is cheaper</h2>

<p>The pitch for going to Kimi K3 was partly cost. And here is my honest verdict after doing real work with it: <strong>I am not sure it actually saved me anything.</strong></p>

<p>The per-token price may well be lower. But cost is not just the sticker rate, it is rate times tokens times number of turns. And on this task:</p>

<ul>
  <li>The chatty TUI and the tool flailing meant more turns.</li>
  <li>The orientation problem meant a large upfront token spend (that 12%).</li>
  <li>More turns and more tokens partly eat whatever the lower per-token rate saves you.</li>
</ul>

<p>So the theoretical savings and the effective savings are not the same number. On a well-scoped task where I hand it tight context, I suspect Kimi K3 could come out genuinely cheaper. On this task — loosely scoped, exploratory, lots of back-and-forth — the efficiency losses clawed back a good part of the discount. I did not come away convinced I had saved money. I came away thinking the savings are real <em>only if</em> you work in a way that keeps token count down, which is exactly the way the tool does not naturally nudge you toward.</p>

<h2 id="would-i-do-it-again">Would I do it again?</h2>

<p>Yes, but differently. The install on Windows 11 via WSL is not the hard part — that genuinely is a ten-minute job. The hard part is everything around the model:</p>

<ul>
  <li>Get the key/path/endpoint triad right the first time (challenge a).</li>
  <li>Make peace with, or scroll past, the noisy TUI (challenge b).</li>
  <li>Front-load project context so you are not paying to explain the obvious (challenge c).</li>
  <li>Be prescriptive about the task so it does not shop for tools (challenge d).</li>
  <li>Do not assume “cheaper per token” means “cheaper overall” (challenge e).</li>
</ul>

<p>Kimi K3 with a 1M context window is a capable backend, and running it under WSL on Windows 11 works fine. But the experience rewards discipline and punishes hand-waving. Go in with tight scope, or go in expecting to pay for the slack.</p>]]></content><author><name>Vikas Deolaliker</name></author><category term="Other" /><summary type="html"><![CDATA[I wrote a companion post about installing Kimi Code and using Kimi K3 to write this blog. That one was the clean, it-all-worked version. This is the other half: what it was actually like to get Kimi K3 running on Windows 11 through WSL, and the five things that made me grind my teeth along the way.]]></summary></entry><entry><title type="html">Installing Kimi K3 on Windows 11 with WSL: The Honest Version</title><link href="https://vdeolali.github.io/2026/07/17/kimi-k3-on-windows-11-wsl-the-challenges.html" rel="alternate" type="text/html" title="Installing Kimi K3 on Windows 11 with WSL: The Honest Version" /><published>2026-07-17T00:00:00+00:00</published><updated>2026-07-17T00:00:00+00:00</updated><id>https://vdeolali.github.io/2026/07/17/kimi-k3-on-windows-11-wsl-the-challenges</id><content type="html" xml:base="https://vdeolali.github.io/2026/07/17/kimi-k3-on-windows-11-wsl-the-challenges.html"><![CDATA[<p>I wrote a companion post about <a href="/2026/07/17/installing-kimi-code-k3-for-this-blog.html">installing Kimi Code and K3 to write this blog (The Clean Version)</a>. That one was the clean, it-all-worked version. This is the other half: what it was actually like to get Kimi K3 running on <strong>Windows 11 through WSL</strong>, and the five things that made me grind my teeth along the way.</p>

<p>None of these are dealbreakers. But if I had read them before I started, I would have saved myself a couple of hours and a chunk of context window I did not need to burn.</p>

<h2 id="why-wsl-at-all">Why WSL at all</h2>

<p>My daily driver is a Windows 11 laptop, but every coding-agent tool I like assumes a Unix shell. Rather than fight that, I run everything inside <strong>WSL2</strong> with an Ubuntu distribution. The Kimi Code CLI and the Claude Code-style terminal flow both behave like they are on Linux, because from their point of view they are.</p>

<p>If you do not already have WSL set up, from an elevated PowerShell:</p>

<pre><code class="language-powershell">wsl --install -d Ubuntu
</code></pre>

<p>Then reboot, let Ubuntu finish first-run setup, and do the rest <strong>inside the WSL shell</strong>, not PowerShell. That distinction matters more than it sounds like, because a lot of the confusion later came from tools and keys that care which environment they were created in.</p>

<p>Inside WSL, the install itself is the easy part:</p>

<pre><code class="language-bash">curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash
kimi --version
</code></pre>

<p>And pointing a Claude Code-style flow at Kimi:</p>

<pre><code class="language-bash">export ANTHROPIC_BASE_URL=https://api.kimi.com/coding/
export ANTHROPIC_API_KEY="your-kimi-code-api-key"
export ANTHROPIC_MODEL="k3[1m]"
</code></pre>

<p>That part took ten minutes. The rest of this post is everything that was <em>not</em> ten minutes.</p>

<h2 id="challenge-a-the-api-key-only-works-with-the-path-you-got-it-for">Challenge (a): the API key only works with the path you got it for</h2>

<p>This was the first wall I hit, and it is the one I most want to warn people about.</p>

<p>Kimi has <strong>three different paths</strong>, and they are not interchangeable:</p>

<ul>
  <li><strong>Kimi Code (Anthropic-compatible)</strong> — <code>https://api.kimi.com/coding/</code></li>
  <li><strong>Kimi Code (OpenAI-compatible)</strong> — <code>https://api.kimi.com/coding/v1</code></li>
  <li><strong>Kimi Open Platform</strong> — <code>https://api.moonshot.cn/v1</code></li>
</ul>

<p>The trap is that a key minted for one path does <strong>not</strong> authenticate against another. I generated a key, wired it in, and got authentication errors that looked like the key was bad. It was not bad. It was a perfectly valid key — for a different door.</p>

<p>The mental model that finally worked for me: the key is scoped to the product, not to “Kimi” in general. If you are doing the Claude Code-style integration, you need a <strong>Kimi Code</strong> key used against the <strong>Kimi Code</strong> base URL, and you have to keep the Anthropic-compatible vs OpenAI-compatible endpoints straight on top of that. Match the key, the base URL, and the API shape as a single unit. If any one of the three is from a different path, you get an error that misleadingly looks like a credentials problem.</p>

<p>Do the check early with <code>/status</code> and confirm the Base URL really is the coding endpoint before you go debugging anything else.</p>

<h2 id="challenge-b-the-tui-sprays-text-while-it-thinks-and-there-is-no-silence-key">Challenge (b): the TUI sprays text while it thinks, and there is no silence key</h2>

<p>Once it authenticates, you meet the interface. The Kimi TUI is <em>busy</em>. While the model is thinking, it streams a running commentary — reasoning, tool deliberation, partial plans — straight into the terminal, continuously.</p>

<p>I understand the intent. Visible thinking is reassuring the first time. By the tenth prompt it is noise. And the part that genuinely annoyed me: there is <strong>no silence key</strong>. No toggle I could find to say “think quietly, show me the result.” You cannot easily mute the stream, so long tasks scroll pages of thinking that you then have to scroll back through to find the actual answer or the actual diff.</p>

<p>On a small screen, this is worse. My WSL terminal window is not huge, and the thinking output pushed the meaningful output — the file it changed, the command it wanted to run — off the top before I could read it. I ended up leaning on my terminal’s own scrollback and search rather than anything the TUI offered.</p>

<p>If you are used to a calmer agent UI, budget some patience for this one.</p>

<h2 id="challenge-c-12-of-a-1m-context-window-just-to-explain-how-the-blog-works">Challenge (c): 12% of a 1M context window just to explain how the blog works</h2>

<p>Here is the one that actually cost me money.</p>

<p>This blog is a plain Jekyll site. Posts live in <code>_posts</code>, the filename encodes the date and slug, and the front matter is three lines. That is the <em>entire</em> mental model. But getting the agent to reliably operate on it — find where posts live, match the existing tone, use the right filename format, not invent config that is not there — took a lot of back-and-forth.</p>

<p>By the time it genuinely understood the repo, I had burned roughly <strong>12% of a 1,000,000-token context window</strong>. That is ~120k tokens spent on orientation, before a single useful line of the post was written.</p>

<p>Some of that is on me. In hindsight the fix is to front-load the context instead of letting the model discover it conversationally:</p>

<ul>
  <li>Keep a short <code>CLAUDE.md</code> / project note describing the repo layout and the post format.</li>
  <li>Point the agent at one existing post as a template up front.</li>
  <li>State the filename convention explicitly rather than making it infer one.</li>
</ul>

<p>But it is worth saying plainly: a large context window is not free, and “let the agent figure out the project” is a surprisingly expensive way to start. The 1M window lulls you into being lazy about scoping, and then you pay for it.</p>

<h2 id="challenge-d-it-struggles-to-pick-the-right-tool-for-the-task">Challenge (d): it struggles to pick the right tool for the task</h2>

<p>The other big time sink was watching it hunt for the right tool. Faced with a simple job — read a file, list a directory, make an edit — it would sometimes reach for the wrong approach first: shelling out where a direct file read would do, or exploring broadly when the target was already known.</p>

<p>Individually these detours are small. Cumulatively they add up, both in wall-clock time and in context consumed (see challenge c — a lot of that 12% was tool flailing). It made the whole effort feel slower and less deterministic than I wanted. The task was never in doubt; the <em>route</em> to it was.</p>

<p>What helped was being more prescriptive. Instead of “add a new blog post,” something closer to “create a file at <code>_posts/YYYY-MM-DD-slug.md</code> with this front matter, matching the style of this existing post” gave it far less room to wander. The less I left to tool discovery, the better it went.</p>

<h2 id="challenge-e-i-am-genuinely-not-sure-it-is-cheaper">Challenge (e): I am genuinely not sure it is cheaper</h2>

<p>The pitch for going to Kimi K3 was partly cost. And here is my honest verdict after doing real work with it: <strong>I am not sure it actually saved me anything.</strong></p>

<p>The per-token price may well be lower. But cost is not just the sticker rate, it is rate times tokens times number of turns. And on this task:</p>

<ul>
  <li>The chatty TUI and the tool flailing meant more turns.</li>
  <li>The orientation problem meant a large upfront token spend (that 12%).</li>
  <li>More turns and more tokens partly eat whatever the lower per-token rate saves you.</li>
</ul>

<p>So the theoretical savings and the effective savings are not the same number. On a well-scoped task where I hand it tight context, I suspect Kimi K3 could come out genuinely cheaper. On this task — loosely scoped, exploratory, lots of back-and-forth — the efficiency losses clawed back a good part of the discount. I did not come away convinced I had saved money. I came away thinking the savings are real <em>only if</em> you work in a way that keeps token count down, which is exactly the way the tool does not naturally nudge you toward.</p>

<h2 id="would-i-do-it-again">Would I do it again?</h2>

<p>Yes, but differently. The install on Windows 11 via WSL is not the hard part — that genuinely is a ten-minute job. The hard part is everything around the model:</p>

<ul>
  <li>Get the key/path/endpoint triad right the first time (challenge a).</li>
  <li>Make peace with, or scroll past, the noisy TUI (challenge b).</li>
  <li>Front-load project context so you are not paying to explain the obvious (challenge c).</li>
  <li>Be prescriptive about the task so it does not shop for tools (challenge d).</li>
  <li>Do not assume “cheaper per token” means “cheaper overall” (challenge e).</li>
</ul>

<p>Kimi K3 with a 1M context window is a capable backend, and running it under WSL on Windows 11 works fine. But the experience rewards discipline and punishes hand-waving. Go in with tight scope, or go in expecting to pay for the slack.</p>]]></content><author><name>Vikas Deolaliker</name></author><category term="Other" /><summary type="html"><![CDATA[I wrote a companion post about installing Kimi Code and K3 to write this blog (The Clean Version). That one was the clean, it-all-worked version. This is the other half: what it was actually like to get Kimi K3 running on Windows 11 through WSL, and the five things that made me grind my teeth along the way.]]></summary></entry><entry><title type="html">Using xAI for Multi-Agent AI Workflows</title><link href="https://vdeolali.github.io/2026/07/11/using-xai-for-multiagent-ai.html" rel="alternate" type="text/html" title="Using xAI for Multi-Agent AI Workflows" /><published>2026-07-11T00:00:00+00:00</published><updated>2026-07-11T00:00:00+00:00</updated><id>https://vdeolali.github.io/2026/07/11/using-xai-for-multiagent-ai</id><content type="html" xml:base="https://vdeolali.github.io/2026/07/11/using-xai-for-multiagent-ai.html"><![CDATA[<p>After spending time with Kimi K3 (see the <a href="/2026/07/17/installing-kimi-code-k3-for-this-blog.html">clean install post</a> and the <a href="/2026/07/17/kimi-k3-on-windows-11-wsl-the-challenges.html">honest challenges</a>), and now diving deep into <strong>Grok Build</strong> (the TUI you’re reading this in), one thing has become clear: <strong>xAI has some of the best primitives for serious multi-agent work</strong> I’ve seen.</p>

<p>Codex, Claude, and most “single agent” tools are fantastic at orchestrating <em>one</em> capable agent. But when you need a <em>network</em> of specialized agents talking to each other, xAI gives you the knobs, the server-side orchestration, and the parallel execution capabilities that feel purpose-built for it.</p>

<p>Here are the pieces that stand out.</p>

<h2 id="1-native-multi-agent-mode-and-spawning">1. Native Multi-Agent Mode and Spawning</h2>

<p>xAI (particularly through Grok Build and the underlying agent SDK) makes it trivial to spawn multiple agents in parallel or sequence.</p>

<p>The subagent system (<code>spawn_subagent</code> tool) lets you launch specialized agents with different roles, models, and capabilities (e.g. <code>explore</code>, <code>plan</code>, <code>general-purpose</code>). You can run them in isolation (using git worktrees so edits don’t collide) or share state.</p>

<p><strong>Use case that just works</strong>: A daily “access token refresh” agent that runs in the background, checks expiration, refreshes credentials across services, and notifies other agents. Spawning this in parallel with your main workflow is clean and doesn’t block.</p>

<h2 id="2-plan-mode-as-the-orchestrator">2. Plan Mode as the Orchestrator</h2>

<p>One of my favorite features is <strong>Plan Mode</strong> (<code>/plan</code> or the dedicated plan agent).</p>

<p>It lets you declare high-level intent and have Grok break it down into a sequenced plan, then execute it — often by chaining multiple specialized agents.</p>

<p>Example:</p>

<pre><code class="language-bash">grok --plan "First, have a researcher agent analyze the SDK docs. Then, have a coder agent implement the client. Finally, have a tester agent write integration tests."
</code></pre>

<p>This is more powerful than it sounds. The planner can:</p>
<ul>
  <li>Decide the sequence</li>
  <li>Spawn the right subagents with the right context</li>
  <li>Use file-based handoffs or shell scripts for communication between agents</li>
</ul>

<p>It’s the closest thing I’ve seen to reliable <em>agent chaining</em> without building your own orchestration layer.</p>

<h2 id="3-server-side-orchestration-and-smart-tool-selection">3. Server-Side Orchestration and Smart Tool Selection</h2>

<p>Unlike purely local tools, xAI does a lot of the heavy lifting server-side: tool selection, parallel execution, context routing, and even some of the reasoning about which agent should handle which part of the task.</p>

<p>This reduces the “tool flailing” I complained about in the Kimi posts. The system is better at picking the right tool (or the right subagent) the first time.</p>

<h2 id="4-cursor-integration">4. Cursor Integration</h2>

<p>The integration with <strong>Cursor</strong> is excellent. Grok Build feels like a natural extension of the Cursor workflow — you get the full power of the TUI (skills, MCP servers, subagents, plan mode, background tasks) while staying inside an editor that already understands your codebase.</p>

<p>It’s not just “chat in the sidebar.” It’s a real multi-agent backend that Cursor can call into.</p>

<h2 id="final-thoughts">Final Thoughts</h2>

<p>xAI doesn’t market itself loudly as an <strong>AI tooling company</strong>. It’s part of the SpaceX family — the brand is rockets, Starlink, and pushing the boundaries of physics and intelligence.</p>

<p>But quietly, the combination of <strong>xAI + Grok Build + Cursor</strong> is becoming one of the strongest setups for high-performance, multi-agent software engineering I’ve used.</p>

<p>It rewards people who want to build <em>networks</em> of agents rather than just prompting one really smart model. The primitives are there: parallel spawning, plan-then-execute chaining, server-side orchestration, clean subagent isolation, and tight editor integration.</p>

<p>If you’re doing serious systems work — infrastructure, complex integrations, or anything that benefits from specialized agents working together — xAI deserves more attention than it gets.</p>

<p>It’s not trying to be the loudest AI company. It’s trying to be one of the most effective.</p>

<p>As one very frustrated user put it while fighting with the TUI: “You are very good at difficult things, but suck at simple things.”</p>

<p>And on that metric, it’s winning.</p>]]></content><author><name>Vikas Deolaliker</name></author><category term="Other" /><summary type="html"><![CDATA[After spending time with Kimi K3 (see the clean install post and the honest challenges), and now diving deep into Grok Build (the TUI you’re reading this in), one thing has become clear: xAI has some of the best primitives for serious multi-agent work I’ve seen.]]></summary></entry><entry><title type="html">Integrating OpenClaw with Gmail, Telegram, and Discord</title><link href="https://vdeolali.github.io/2026/05/07/openclaw-gmail-telegram-discord-integration.html" rel="alternate" type="text/html" title="Integrating OpenClaw with Gmail, Telegram, and Discord" /><published>2026-05-07T00:00:00+00:00</published><updated>2026-05-07T00:00:00+00:00</updated><id>https://vdeolali.github.io/2026/05/07/openclaw-gmail-telegram-discord-integration</id><content type="html" xml:base="https://vdeolali.github.io/2026/05/07/openclaw-gmail-telegram-discord-integration.html"><![CDATA[<p>I wrote a shorter post earlier on my old blog, <a href="https://www.networkofthings.com/2026_04_01_archive.html#4651470299340435740">OpenClaw is MicroSaaS</a>, after I got OpenClaw working against my Gmail. That post was more about the experience. This one is the technical version: what OpenClaw is actually doing under the hood, why the integration takes real setup effort, and how I wired it into <strong>Gmail</strong>, <strong>Telegram</strong>, and <strong>Discord</strong> from a local machine.</p>

<p>The short version is this: OpenClaw is not just a chat bot with a pretty shell. It is a <strong>local-first gateway</strong> that owns messaging surfaces, tools, sessions, and automation. Once I started looking at it that way, the setup made much more sense.</p>

<h2 id="what-openclaw-actually-is">What OpenClaw actually is</h2>

<p>The upstream project is <a href="https://github.com/openclaw/openclaw">openclaw/openclaw</a>, and the architecture is centered around a long-lived local <strong>Gateway</strong>. The Gateway exposes a WebSocket control plane, maintains channel connections, and routes events into agent sessions.</p>

<p>That architecture matters because the three integrations I set up do not all enter the system the same way:</p>

<ul>
  <li><strong>Telegram</strong> is a native messaging channel connected directly to the OpenClaw gateway.</li>
  <li><strong>Discord</strong> is also a native channel, but it needs a Discord application, bot token, gateway intents, and then pairing or allowlist configuration inside OpenClaw.</li>
  <li><strong>Gmail</strong> is different. It is not just “another chat channel”. In practice it becomes an <strong>event-driven integration</strong> using <code>gog</code>, Google OAuth, Gmail watch, Google Pub/Sub, and OpenClaw hooks.</li>
</ul>

<p>That is why the setup feels long. You are really stitching together several trust boundaries:</p>

<ol>
  <li>Your model provider auth for the LLM itself.</li>
  <li>Your local OpenClaw gateway runtime.</li>
  <li>Google OAuth plus Pub/Sub for Gmail.</li>
  <li>Bot credentials and access control for Telegram and Discord.</li>
</ol>

<h2 id="my-setup-assumptions">My setup assumptions</h2>

<p>I did this on <strong>WSL2</strong>, which OpenClaw explicitly recommends for Windows users. The upstream docs currently recommend <strong>Node 24</strong> or at least <strong>Node 22.16+</strong>.</p>

<p>At a minimum, I would have these pieces ready before starting:</p>

<ul>
  <li><code>node</code> and <code>npm</code></li>
  <li><code>openclaw</code></li>
  <li><code>gcloud</code></li>
  <li><code>gog</code></li>
  <li>a model account or API key for the LLM provider I want OpenClaw to use</li>
  <li>a Telegram bot token from <code>@BotFather</code></li>
  <li>a Discord bot token from the Discord Developer Portal</li>
</ul>

<p>The recommended install path is:</p>

<pre><code class="language-bash">npm install -g openclaw@latest
openclaw onboard --install-daemon
</code></pre>

<p>If I want to run it in the foreground while debugging:</p>

<pre><code class="language-bash">openclaw gateway --port 18789 --verbose
</code></pre>

<p>The important mental model is that the gateway is the control plane. Once it is up, the channels and automations hang off of it.</p>

<h2 id="why-gmail-is-the-hardest-integration">Why Gmail is the hardest integration</h2>

<p>Telegram and Discord are basically messaging front ends. Gmail is not.</p>

<p>For Gmail, I had to solve three things:</p>

<ul>
  <li>OAuth access to the mailbox</li>
  <li>a way for Gmail to notify my local OpenClaw stack when new mail lands</li>
  <li>a safe handoff path from that notification into an OpenClaw agent session</li>
</ul>

<p>OpenClaw’s docs handle this with <strong>Gmail Pub/Sub integration</strong> and the <code>gog</code> CLI.</p>

<h3 id="step-1-authorize-gog-for-google-services">Step 1: authorize <code>gog</code> for Google services</h3>

<p>OpenClaw uses <code>gog</code> for Google Workspace access. The setup is:</p>

<pre><code class="language-bash">gog auth credentials /path/to/client_secret.json
gog auth add you@gmail.com --services gmail,calendar,drive,contacts,docs,sheets
gog auth list
</code></pre>

<p>That gives <code>gog</code> the OAuth credentials it needs to access Gmail and related Google APIs.</p>

<h3 id="step-2-enable-gmail-api-and-pubsub">Step 2: enable Gmail API and Pub/Sub</h3>

<p>The OpenClaw docs use Google Cloud Pub/Sub as the event path for Gmail watch notifications:</p>

<pre><code class="language-bash">gcloud auth login
gcloud config set project &lt;project-id&gt;
gcloud services enable gmail.googleapis.com pubsub.googleapis.com
</code></pre>

<p>Then create a topic and grant Gmail permission to publish into it:</p>

<pre><code class="language-bash">gcloud pubsub topics create gog-gmail-watch
gcloud pubsub topics add-iam-policy-binding gog-gmail-watch \
  --member=serviceAccount:gmail-api-push@system.gserviceaccount.com \
  --role=roles/pubsub.publisher
</code></pre>

<h3 id="step-3-start-the-gmail-watch">Step 3: start the Gmail watch</h3>

<p>The lower-level manual path is:</p>

<pre><code class="language-bash">gog gmail watch start \
  --account you@gmail.com \
  --label INBOX \
  --topic projects/&lt;project-id&gt;/topics/gog-gmail-watch
</code></pre>

<p>The OpenClaw-friendly path is simpler:</p>

<pre><code class="language-bash">openclaw webhooks gmail setup --account you@gmail.com
</code></pre>

<p>That command writes <code>hooks.gmail</code> configuration, enables the Gmail preset, and prepares the push endpoint that the gateway will use.</p>

<h3 id="step-4-let-the-gateway-own-the-watcher">Step 4: let the gateway own the watcher</h3>

<p>This is the part that made the architecture click for me. Once <code>hooks.enabled=true</code> and <code>hooks.gmail.account</code> is configured, the <strong>gateway starts <code>gog gmail watch serve</code> on boot and auto-renews the watch</strong>.</p>

<p>So the runtime flow becomes:</p>

<ol>
  <li>Gmail sees a mailbox event.</li>
  <li>Gmail publishes the event to the Pub/Sub topic.</li>
  <li><code>gog</code> receives and normalizes that watch event.</li>
  <li>OpenClaw hooks ingest it.</li>
  <li>The gateway routes the event into an agent session.</li>
</ol>

<p>That is a very different pattern from a bot reading chat messages, and it explains why Gmail setup feels more like building an automation pipeline than enabling a simple inbox plugin.</p>

<h2 id="telegram-integration">Telegram integration</h2>

<p>Telegram was much more straightforward.</p>

<h3 id="step-1-create-a-bot-in-botfather">Step 1: create a bot in BotFather</h3>

<p>In Telegram, I created a bot with <code>@BotFather</code>, ran <code>/newbot</code>, and saved the token.</p>

<h3 id="step-2-configure-the-channel-in-openclaw">Step 2: configure the channel in OpenClaw</h3>

<p>OpenClaw’s Telegram configuration can be as simple as:</p>

<pre><code class="language-json5">{
  channels: {
    telegram: {
      enabled: true,
      botToken: "123:abc",
      dmPolicy: "pairing",
      groups: { "*": { requireMention: true } },
    },
  },
}
</code></pre>

<p>Telegram does not use a separate <code>openclaw channels login telegram</code> flow. I just configure the token and start the gateway.</p>

<h3 id="step-3-approve-the-first-dm">Step 3: approve the first DM</h3>

<p>With <code>dmPolicy: "pairing"</code>, the first DM from an unknown sender gets a code instead of being processed immediately.</p>

<pre><code class="language-bash">openclaw pairing list telegram
openclaw pairing approve telegram &lt;CODE&gt;
</code></pre>

<p>That pairing step is one of the better design choices in OpenClaw. It keeps my personal assistant from turning into a public endpoint just because someone discovers the bot username.</p>

<h3 id="step-4-optional-group-behavior">Step 4: optional group behavior</h3>

<p>If I want the bot in a group, I can add the bot to the group and then decide whether the group needs explicit mention or always-on behavior. Telegram also has its own privacy-mode behavior, so group visibility depends partly on BotFather settings and partly on OpenClaw config.</p>

<p>For a private group, I would usually stay restrictive first and then loosen it only if I really want ambient participation.</p>

<h2 id="discord-integration">Discord integration</h2>

<p>Discord was somewhere in between Telegram and Gmail. It is not as operationally heavy as Gmail, but it is definitely more involved than Telegram because Discord wants a properly configured application and bot.</p>

<h3 id="step-1-create-the-application-and-bot">Step 1: create the application and bot</h3>

<p>In the Discord Developer Portal, I created a new application, added a bot, and copied the bot token.</p>

<p>The critical part is enabling the right gateway intents:</p>

<ul>
  <li><strong>Message Content Intent</strong> is required.</li>
  <li><strong>Server Members Intent</strong> is recommended.</li>
  <li><strong>Presence Intent</strong> is optional.</li>
</ul>

<h3 id="step-2-invite-the-bot-with-usable-permissions">Step 2: invite the bot with usable permissions</h3>

<p>From the OAuth2 URL Generator, I enabled:</p>

<ul>
  <li><code>bot</code></li>
  <li><code>applications.commands</code></li>
</ul>

<p>And I made sure the bot had at least:</p>

<ul>
  <li>View Channels</li>
  <li>Send Messages</li>
  <li>Read Message History</li>
  <li>Embed Links</li>
  <li>Attach Files</li>
</ul>

<p>If I wanted thread-heavy usage, I would also enable <strong>Send Messages in Threads</strong>.</p>

<h3 id="step-3-configure-the-token-in-openclaw">Step 3: configure the token in OpenClaw</h3>

<p>I prefer not to store bot tokens inline, so an environment-backed config is cleaner:</p>

<pre><code class="language-bash">export DISCORD_BOT_TOKEN="YOUR_BOT_TOKEN"
</code></pre>

<pre><code class="language-json5">{
  channels: {
    discord: {
      enabled: true,
      token: { source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" },
    },
  },
}
</code></pre>

<p>Then I can start the gateway and pair from Discord DM.</p>

<h3 id="step-4-approve-the-first-dm-pairing">Step 4: approve the first DM pairing</h3>

<p>Once the gateway is running, I DM the bot in Discord and approve the pairing code:</p>

<pre><code class="language-bash">openclaw pairing list discord
openclaw pairing approve discord &lt;CODE&gt;
</code></pre>

<p>At that point Discord DMs behave like a controlled front end into my assistant.</p>

<h3 id="step-5-make-a-private-server-usable">Step 5: make a private server usable</h3>

<p>For a private guild, I can explicitly allow my server and user ID:</p>

<pre><code class="language-json5">{
  channels: {
    discord: {
      groupPolicy: "allowlist",
      guilds: {
        YOUR_SERVER_ID: {
          requireMention: true,
          users: ["YOUR_USER_ID"],
        },
      },
    },
  },
}
</code></pre>

<p>That is a good default because it keeps the bot scoped to a server I control instead of wandering across whatever guilds it happens to be invited into.</p>

<h2 id="the-integration-pattern-that-finally-made-sense-to-me">The integration pattern that finally made sense to me</h2>

<p>Once I had all three running, the OpenClaw model became much clearer:</p>

<ul>
  <li><strong>Telegram</strong> and <strong>Discord</strong> are conversational ingress channels.</li>
  <li><strong>Gmail</strong> is an automation ingress path.</li>
  <li>The <strong>Gateway</strong> is the stable center of the system.</li>
</ul>

<p>That is the part I missed the first time around. I originally thought of OpenClaw as “an AI that can check my Gmail”. Technically, it is closer to a <strong>personal agent gateway</strong> with multiple inbound surfaces and a local execution/control plane.</p>

<p>That explains the setup complexity, but it also explains the power. Once the integrations are in place, I can ask questions across those surfaces in a way that a conventional email client or single-channel bot does not really support.</p>

<p>For example, I can use Gmail as the data source, then talk to the assistant through Telegram or Discord, and let the same gateway manage the session, tooling, and response path.</p>

<h2 id="security-notes-that-matter">Security notes that matter</h2>

<p>This stack touches real personal communications, so I would not run it casually without guardrails.</p>

<p>The OpenClaw docs are pretty explicit about a few things that I agree with:</p>

<ul>
  <li>keep DM policy on <strong>pairing</strong> or explicit allowlists</li>
  <li>keep Gmail hooks behind loopback, tailnet, or a trusted reverse proxy</li>
  <li>use a dedicated hook token instead of reusing the gateway auth token</li>
  <li>keep the bot in private Telegram groups or private Discord servers unless there is a real reason not to</li>
</ul>

<p>The right default mindset is that inbound messages are <strong>untrusted input</strong>.</p>

<h2 id="what-i-would-tell-someone-before-they-try-this">What I would tell someone before they try this</h2>

<p>If someone wants to integrate OpenClaw with Gmail, Telegram, and Discord, this is what I would tell them up front:</p>

<ul>
  <li><strong>Telegram is the easiest</strong>. Get a bot token, start the gateway, approve pairing.</li>
  <li><strong>Discord is manageable</strong>, but you need to be careful with intents, permissions, and allowlists.</li>
  <li><strong>Gmail is the most technical</strong> because it is really a Google OAuth plus Pub/Sub plus webhook pipeline.</li>
  <li><strong>WSL2 is a good place to do this on Windows</strong> because that is the path OpenClaw itself recommends.</li>
  <li><strong>The local gateway is the whole point</strong>. If that architectural model does not make sense to you yet, the rest of the setup will feel random and frustrating.</li>
</ul>

<h2 id="closing-thought">Closing thought</h2>

<p>What impressed me most about OpenClaw was not that it could read my email. Plenty of tools can do that. What impressed me was that it gave me a way to unify <strong>personal messaging channels</strong>, <strong>automation hooks</strong>, and <strong>agent execution</strong> around a local gateway that I control.</p>

<p>That is also why the setup is not trivial. The project is doing real systems integration work, not just wrapping an LLM in a chat window.</p>]]></content><author><name>Vikas Deolaliker</name></author><category term="Other" /><summary type="html"><![CDATA[I wrote a shorter post earlier on my old blog, OpenClaw is MicroSaaS, after I got OpenClaw working against my Gmail. That post was more about the experience. This one is the technical version: what OpenClaw is actually doing under the hood, why the integration takes real setup effort, and how I wired it into Gmail, Telegram, and Discord from a local machine.]]></summary></entry><entry><title type="html">OpenShift Multi-Network Pod Configuration</title><link href="https://vdeolali.github.io/2026/04/15/openshift-multi-network-pod-configuration.html" rel="alternate" type="text/html" title="OpenShift Multi-Network Pod Configuration" /><published>2026-04-15T00:00:00+00:00</published><updated>2026-04-15T00:00:00+00:00</updated><id>https://vdeolali.github.io/2026/04/15/openshift-multi-network-pod-configuration</id><content type="html" xml:base="https://vdeolali.github.io/2026/04/15/openshift-multi-network-pod-configuration.html"><![CDATA[<p>When people say “OpenShift networking”, they are usually talking about more than one thing. There is the default cluster network that every pod gets, and then there are secondary networks that you can attach only to the workloads that need them. That distinction matters because the right choice depends on whether you want simple pod-to-pod reachability, direct access to a physical underlay, or near line-rate access to a device.</p>

<p>In practice, I usually bucket the common OpenShift options like this:</p>

<ul>
  <li><strong>OVN-Kubernetes default network</strong> for the standard pod network every workload gets</li>
  <li><strong>OVN-Kubernetes secondary networks</strong>
    <ul>
      <li><code>layer2</code> when you want an isolated L2 domain for east-west traffic</li>
      <li><code>localnet</code> when you want that L2 domain to extend to a physical network</li>
    </ul>
  </li>
  <li><strong>macvlan</strong> when a pod needs a MAC on the external segment and you are comfortable with the macvlan communication model</li>
  <li><strong>bridge</strong> when you want a pod attached through a Linux bridge-based secondary network</li>
  <li><strong>SR-IOV</strong> when you need direct VF assignment, low overhead, and often VLAN-backed segmentation</li>
</ul>

<p>For this post, I am focusing on <strong>OVN-Kubernetes localnet</strong>, because it gives a clean path to attach selected pods to an external network without changing the primary pod network model for the cluster.</p>

<p>The key OpenShift-specific piece in this workflow is the <strong>NodeNetworkConfigurationPolicy (NNCP)</strong>. The NNCP is what programs the worker node networking so OVN-Kubernetes knows which host bridge should carry the <code>localnet</code> traffic.</p>

<h2 id="where-localnet-fits">Where <code>localnet</code> fits</h2>

<p><code>localnet</code> is an OVN-Kubernetes secondary network topology. It is useful when a pod needs:</p>

<ul>
  <li>access to an existing physical subnet</li>
  <li>access to an external gateway on that subnet</li>
  <li>a second interface in addition to the default pod interface</li>
</ul>

<p>Conceptually, the flow is:</p>

<ol>
  <li>Create a <strong>NodeNetworkConfigurationPolicy (NNCP)</strong> to define the host-side bridge mapping on the worker nodes.</li>
  <li>Create a <code>NetworkAttachmentDefinition</code> that declares a <code>localnet</code> topology.</li>
  <li>Attach a pod to that NAD with a Multus network annotation.</li>
  <li>Exec into the pod and validate that the secondary interface and connectivity behave as expected.</li>
</ol>

<h2 id="prerequisites">Prerequisites</h2>

<p>Before applying manifests, make sure the following are true:</p>

<ul>
  <li>The cluster is using <strong>OVN-Kubernetes</strong>.</li>
  <li>The <strong>Kubernetes NMState Operator</strong> is installed.</li>
  <li>Multus secondary networks are available.</li>
  <li>The worker nodes can reach the target underlay network through the bridge you map.</li>
  <li>If the physical network is VLAN-tagged, the switch ports connected to the workers allow that VLAN.</li>
</ul>

<h2 id="step-1-create-the-namespace">Step 1: Create the namespace</h2>

<pre><code class="language-bash">oc create namespace test-net
</code></pre>

<h2 id="step-2-create-the-nncp-bridge-mapping">Step 2: Create the NNCP bridge mapping</h2>

<p>Use <a href="/assets/manifests/localnet/vlan200-nncp-ovn-localnet.yaml">vlan200-nncp-ovn-localnet.yaml</a> as the <code>NodeNetworkConfigurationPolicy</code> for the <code>localnet</code> bridge mapping.</p>

<p>Apply it:</p>

<pre><code class="language-bash">oc apply -f assets/manifests/localnet/vlan200-nncp-ovn-localnet.yaml
oc get nncp br-ex-localnet
</code></pre>

<p>What this NNCP does:</p>

<ul>
  <li>creates an OVN bridge mapping named <code>vlan200-ovn-localnet</code></li>
  <li>maps that logical <code>localnet</code> name to <code>br-ex</code></li>
  <li>makes <code>br-ex</code> the worker-side bridge used for the secondary network</li>
</ul>

<p>The most important relationship is this one:</p>

<p><code>localnet: vlan200-ovn-localnet</code></p>

<p>That value must match the <code>physicalNetworkName</code> used later in the <code>NetworkAttachmentDefinition</code>.</p>

<p>If the NNCP is missing, the NAD alone is not enough. The pod might get the secondary attachment definition, but there will be no correct host-side mapping to the external network.</p>

<h3 id="optional-mapping-a-dedicated-ovs-bridge">Optional: mapping a dedicated OVS bridge</h3>

<p>If <code>br-ex</code> is not the right bridge for your environment, the same NNCP pattern can be used with a dedicated OVS bridge instead. Use that approach carefully, because moving an active NIC under a new bridge on a worker can break connectivity if the host networking design is not planned first.</p>

<h2 id="step-3-create-the-networkattachmentdefinition">Step 3: Create the <code>NetworkAttachmentDefinition</code></h2>

<p>Use <a href="/assets/manifests/localnet/vlan200-nad-ovn-localnet.yaml">vlan200-nad-ovn-localnet.yaml</a> as the <code>NetworkAttachmentDefinition</code>.</p>

<p>Apply it:</p>

<pre><code class="language-bash">oc apply -f assets/manifests/localnet/vlan200-nad-ovn-localnet.yaml
oc get network-attachment-definition -n test-net
</code></pre>

<p>What matters here:</p>

<ul>
  <li><code>topology: localnet</code> tells OVN-Kubernetes this is a localnet secondary network.</li>
  <li><code>physicalNetworkName: vlan200-ovn-localnet</code> must match the <code>localnet</code> name from the NNCP bridge mapping.</li>
  <li><code>subnets</code> enables OVN-managed address allocation on the secondary interface.</li>
  <li><code>netAttachDefName</code> must match the namespace and NAD name exactly.</li>
</ul>

<p>If your external network is VLAN-tagged, add a VLAN ID in the NAD:</p>

<pre><code class="language-json">"vlanID": 100
</code></pre>

<p>That only works if the underlay path between the worker and the physical network is prepared for that VLAN.</p>

<h2 id="step-4-launch-a-test-pod">Step 4: Launch a test pod</h2>

<p>Use <a href="/assets/manifests/localnet/test-nad.yaml">test-nad.yaml</a> for the sample pod. It keeps the default pod network and adds a second interface from the <code>vlan200-ovn-localnet</code> NAD.</p>

<p>Apply it:</p>

<pre><code class="language-bash">oc apply -f assets/manifests/localnet/test-nad.yaml
oc get pod -n test-net -w
</code></pre>

<p>Once the pod is <code>Running</code>, inspect the Multus status annotation:</p>

<pre><code class="language-bash">oc get pod test-nad -n test-net \
  -o jsonpath='{.metadata.annotations.k8s\.v1\.cni\.cncf\.io/network-status}'
</code></pre>

<p>You should see the default network plus the <code>vlan200-ovn-localnet</code> attachment.</p>

<h2 id="step-5-exec-into-the-pod-and-validate">Step 5: Exec into the pod and validate</h2>

<p>Get an interactive shell:</p>

<pre><code class="language-bash">oc exec -it -n test-net pod/test-nad -- sh
</code></pre>

<p>Inside the pod, check the interfaces:</p>

<pre><code class="language-bash">ip addr show
ip -br addr
ip route
</code></pre>

<p>You should see:</p>

<ul>
  <li><code>eth0</code> for the normal cluster pod network</li>
  <li><code>net1</code> for the localnet secondary network created by Multus</li>
</ul>

<p>To inspect the secondary interface directly:</p>

<pre><code class="language-bash">ip addr show dev net1
ip route show dev net1
ip neigh show dev net1
</code></pre>

<p>To test L2 and L3 connectivity on the localnet attachment:</p>

<pre><code class="language-bash">ping -I net1 10.115.16.1
arping -I net1 10.115.16.1
</code></pre>

<p>Pick targets that actually exist on your external subnet. In this setup, <code>10.115.16.0/20</code> is the configured secondary network, so start with the expected gateway or another reachable host on that segment.</p>

<p>In a real environment that might be:</p>

<ul>
  <li>the subnet gateway</li>
  <li>a legacy appliance</li>
  <li>a bare metal host</li>
  <li>a service IP reachable only from the underlay</li>
</ul>

<p>If DNS is not part of the localnet path, test by IP first before assuming the network itself is broken.</p>

<h2 id="useful-verification-commands-from-the-cluster-side">Useful verification commands from the cluster side</h2>

<p>These are the commands I usually run while troubleshooting:</p>

<pre><code class="language-bash">oc get nncp
oc describe nncp br-ex-localnet
oc get nns
oc get network-attachment-definition -n test-net
oc describe pod test-nad -n test-net
</code></pre>

<p>To see the network-status annotation in a readable form:</p>

<pre><code class="language-bash">oc get pod test-nad -n test-net -o json | jq '.metadata.annotations["k8s.v1.cni.cncf.io/network-status"] | fromjson'
</code></pre>

<h2 id="common-failure-points">Common failure points</h2>

<p>When <code>localnet</code> does not work, the problem is usually one of these:</p>

<ul>
  <li>The NNCP bridge mapping has not rolled out successfully to the worker nodes.</li>
  <li><code>physicalNetworkName</code> in the NAD does not match the NNCP <code>localnet</code> mapping name.</li>
  <li>The external bridge does not actually reach the target subnet.</li>
  <li>The gateway IP was accidentally included in the assignable pool.</li>
  <li>The physical switch is not allowing the VLAN you configured in the NAD.</li>
  <li>The pod is running on a node that does not have the expected bridge mapping.</li>
</ul>

<h2 id="final-thoughts">Final thoughts</h2>

<p>For most clusters, I think of the choices this way:</p>

<ul>
  <li>use <strong>OVN-Kubernetes layer2</strong> when you only need isolated east-west connectivity</li>
  <li>use <strong>OVN-Kubernetes localnet</strong> when workloads need a clean path to an existing physical network</li>
  <li>use <strong>macvlan</strong> or <strong>bridge</strong> for simpler CNI-based attachments when OVN secondary topology is not the goal</li>
  <li>use <strong>SR-IOV</strong> when direct VF performance is the requirement</li>
</ul>

<p><code>localnet</code> is a strong middle ground. You keep the normal OpenShift pod networking model, but you can still attach the selected workloads that need underlay access through a second interface.</p>

<h2 id="references">References</h2>

<ul>
  <li><a href="https://docs.redhat.com/en/documentation/openshift_container_platform/latest/html/multiple_networks/understanding-multiple-networks">OpenShift documentation: understanding multiple networks</a></li>
  <li><a href="https://ovn-kubernetes.io/features/multiple-networks/multi-homing/">OVN-Kubernetes multi-homing and secondary network topologies</a></li>
  <li><a href="https://redhatquickcourses.github.io/ocp-virt-cookbook/ocp-virt-cookbook/1/networking/localnet-vlan.html">Example of localnet bridge mapping and VLAN-backed NAD</a></li>
</ul>]]></content><author><name>Vikas Deolaliker</name></author><category term="Other" /><summary type="html"><![CDATA[When people say “OpenShift networking”, they are usually talking about more than one thing. There is the default cluster network that every pod gets, and then there are secondary networks that you can attach only to the workloads that need them. That distinction matters because the right choice depends on whether you want simple pod-to-pod reachability, direct access to a physical underlay, or near line-rate access to a device.]]></summary></entry><entry><title type="html">Building Custom OCI Images with Flask and Packer</title><link href="https://vdeolali.github.io/2025/08/25/building-custom-oci-images-with-flask-and-packer.html" rel="alternate" type="text/html" title="Building Custom OCI Images with Flask and Packer" /><published>2025-08-25T00:00:00+00:00</published><updated>2025-08-25T00:00:00+00:00</updated><id>https://vdeolali.github.io/2025/08/25/building-custom-oci-images-with-flask-and-packer</id><content type="html" xml:base="https://vdeolali.github.io/2025/08/25/building-custom-oci-images-with-flask-and-packer.html"><![CDATA[<p>I recently spent some time reading through my <a href="https://github.com/vdeolali/oci-image-builder"><code>oci-image-builder</code></a> repository. The project is a small web application that helps build a custom Oracle Cloud Infrastructure image starting from an existing OCI platform image, adding packages, and then handing the actual image creation off to Packer.</p>

<p>What I like about this repo is that it keeps the workflow simple:</p>

<ul>
  <li>use a Flask UI to collect the image build inputs</li>
  <li>query OCI dynamically for the available profiles, images, and shapes</li>
  <li>store each build request in SQLite</li>
  <li>generate a Packer template from the submitted form data</li>
  <li>launch a Packer build that creates a new image in OCI</li>
</ul>

<p>That makes it less of a giant platform and more of a focused control plane for one job: turning a repeatable image customization request into an OCI image build.</p>

<h2 id="what-the-application-does">What the application does</h2>

<p>At a high level, the app gives the user a web form with these fields:</p>

<ul>
  <li>OCI profile</li>
  <li>base image</li>
  <li>instance shape</li>
  <li>optional Flex shape sizing</li>
  <li>a package list to install</li>
</ul>

<p>From there, the app creates a build record and starts the image build asynchronously.</p>

<p>The code path is straightforward:</p>

<ol>
  <li>The Flask app in <a href="https://github.com/vdeolali/oci-image-builder/blob/main/app.py"><code>app.py</code></a> renders the build form.</li>
  <li>The OCI helper functions in <a href="https://github.com/vdeolali/oci-image-builder/blob/main/oci_utils.py"><code>oci_utils.py</code></a> read the local OCI CLI config and query OCI for profiles, Oracle Linux images, and shapes.</li>
  <li>The form submission is stored as an <code>ImageBuild</code> record through SQLAlchemy in <a href="https://github.com/vdeolali/oci-image-builder/blob/main/models.py"><code>models.py</code></a>.</li>
  <li>A background thread calls <a href="https://github.com/vdeolali/oci-image-builder/blob/main/packer_utils.py"><code>run_packer_build</code></a> to generate a JSON template and execute <code>packer build</code>.</li>
  <li>The build status and captured output are written back to the database.</li>
</ol>

<p>That separation is clean enough that you can understand the system quickly:</p>

<ul>
  <li>Flask handles the UI and request lifecycle</li>
  <li>OCI SDK calls handle discovery</li>
  <li>SQLAlchemy tracks state</li>
  <li>Packer performs the actual image creation</li>
</ul>

<h2 id="the-ui-flow">The UI flow</h2>

<p>The HTML templates are basic Bootstrap templates, which is fine for this type of tool. The important part is the user flow in <a href="https://github.com/vdeolali/oci-image-builder/blob/main/templates/index.html"><code>templates/index.html</code></a>.</p>

<p>When the page loads, JavaScript calls two Flask API endpoints:</p>

<ul>
  <li><code>/api/images/&lt;profile_name&gt;</code></li>
  <li><code>/api/shapes/&lt;profile_name&gt;</code></li>
</ul>

<p>Those endpoints return JSON that populates the dropdowns based on the selected OCI profile. That means the form is not hardcoded to a single environment. Instead, it uses the OCI configuration already present on the system and lets the user choose which profile to authenticate with.</p>

<p>The shape handling is also practical. If the selected shape contains <code>Flex</code>, the page reveals fields for:</p>

<ul>
  <li><code>ocpus</code></li>
  <li><code>memory_in_gbs</code></li>
</ul>

<p>That is a small touch, but it matches how OCI actually works with Flex shapes and avoids forcing those parameters for fixed-size shapes.</p>

<h2 id="how-the-oci-integration-works">How the OCI integration works</h2>

<p>The OCI-specific logic lives in <a href="https://github.com/vdeolali/oci-image-builder/blob/main/oci_utils.py"><code>oci_utils.py</code></a>.</p>

<p>There are three main helpers:</p>

<ul>
  <li><code>get_oci_profiles()</code> parses the local OCI config file and returns the available profile names.</li>
  <li><code>get_oci_images()</code> uses the OCI Python SDK to list available Oracle Linux images in the configured compartment.</li>
  <li><code>get_available_shapes()</code> lists the shapes available in the compartment.</li>
</ul>

<p>This design makes the tool tenancy-aware without forcing the user to manually paste long OCIDs into the form every time.</p>

<p>A few implementation details stood out to me:</p>

<ul>
  <li>the OCI config file path is taken from <code>~/.oci/config</code></li>
  <li>the compartment, subnet, and availability domain are loaded from environment variables</li>
  <li>the image list is filtered to <code>operating_system='Oracle Linux'</code></li>
</ul>

<p>That last point is important. Right now the app is opinionated toward Oracle Linux based image builds, which is a reasonable place to start for OCI-focused automation.</p>

<h2 id="how-the-build-request-is-persisted">How the build request is persisted</h2>

<p>The model is intentionally small. The <code>ImageBuild</code> table stores:</p>

<ul>
  <li>cloud provider</li>
  <li>OCI profile</li>
  <li>base image</li>
  <li>package list</li>
  <li>shape</li>
  <li>Flex sizing values when applicable</li>
  <li>status</li>
  <li>captured Packer output</li>
  <li>submission timestamp</li>
</ul>

<p>For a tool like this, SQLite is a sensible default. It keeps the application lightweight and easy to run locally without requiring an external database just to track a build queue.</p>

<p>The app also forces the database path into the local <code>instance</code> directory in <a href="https://github.com/vdeolali/oci-image-builder/blob/main/app.py"><code>app.py</code></a>, which avoids some of the common relative-path confusion that often shows up in simple Flask projects.</p>

<h2 id="how-the-packer-template-is-generated">How the Packer template is generated</h2>

<p>The most important backend logic is in <a href="https://github.com/vdeolali/oci-image-builder/blob/main/packer_utils.py"><code>packer_utils.py</code></a>.</p>

<p>When a build starts, the code:</p>

<ol>
  <li>splits the submitted package list into lines</li>
  <li>converts those package names into a <code>yum install -y</code> command</li>
  <li>loads the selected OCI profile from the local OCI config</li>
  <li>extracts the authentication fields Packer needs</li>
  <li>builds a JSON document for the <code>oracle-oci</code> Packer plugin</li>
  <li>writes that JSON to a temporary <code>.pkr.json</code> file</li>
  <li>runs <code>packer build -force</code></li>
</ol>

<p>The generated provisioner is simple and clear:</p>

<pre><code class="language-json">"inline": [
  "sudo yum update -y",
  "sudo yum install -y ..."
]
</code></pre>

<p>That means the repository is currently optimized for package-based customization instead of a more general provisioning pipeline with shell scripts, Ansible, or cloud-init fragments. For many internal image-builder use cases, that is enough to be immediately useful.</p>

<p>Another detail I liked is that the code injects the OCI authentication fields directly into the Packer source block rather than depending on external shell state at build time. That reduces ambiguity around which OCI profile is actually being used.</p>

<h2 id="why-the-async-build-model-makes-sense">Why the async build model makes sense</h2>

<p>The application starts the Packer work in a background thread after saving the build request. That is the right tradeoff for a simple Flask app because image creation is slow and should not block the request thread while the browser waits for a response.</p>

<p>The build history view in <a href="https://github.com/vdeolali/oci-image-builder/blob/main/templates/builds.html"><code>templates/builds.html</code></a> then gives a simple status table showing:</p>

<ul>
  <li>build ID</li>
  <li>cloud</li>
  <li>base image</li>
  <li>status</li>
  <li>submission time</li>
</ul>

<p>It is intentionally minimal, but it is enough to confirm that a request was queued and whether it completed or failed.</p>

<h2 id="what-i-think-this-project-does-well">What I think this project does well</h2>

<p>After reading the code, I think the repo does a few things particularly well:</p>

<ul>
  <li>It keeps the problem scope tight.</li>
  <li>It uses OCI discovery instead of hardcoded dropdown values.</li>
  <li>It records build state in a way that is easy to inspect.</li>
  <li>It generates Packer configuration programmatically from user input.</li>
  <li>It supports Flex shapes, which matters in OCI.</li>
</ul>

<p>Most importantly, it captures a workflow that many teams eventually want: “start from a known OCI image, add the packages we care about, and produce a reusable custom image.”</p>

<h2 id="where-i-would-extend-it-next">Where I would extend it next</h2>

<p>The current code works as a solid first version, but reading it also made the next set of improvements pretty obvious:</p>

<ul>
  <li>filter shapes based on base image compatibility instead of listing all shapes</li>
  <li>support more provisioning options than package installation alone</li>
  <li>add stronger validation around package input and numeric Flex fields</li>
  <li>surface the packer output in the UI instead of only storing it in the database</li>
  <li>move background execution to a real task queue if concurrency grows</li>
  <li>add migrations for schema changes instead of relying on ad hoc database updates</li>
</ul>

<p>None of those points take away from the value of the current implementation. They are mostly signs that the repo has reached the stage where a working prototype can evolve into a more durable internal tool.</p>

<h2 id="final-thoughts">Final thoughts</h2>

<p>The main idea behind <code>oci-image-builder</code> is practical: wrap OCI discovery and Packer execution in a small web interface so a repeatable image build becomes easier to launch and track.</p>

<p>That combination works well here:</p>

<ul>
  <li>Flask provides a lightweight front end</li>
  <li>the OCI SDK supplies tenancy-aware discovery</li>
  <li>SQLAlchemy keeps build metadata</li>
  <li>Packer performs the actual image creation</li>
</ul>

<p>For anyone building custom Oracle Linux based images on OCI, this is a useful pattern. You do not need a large image factory platform to get value. A small app with the right boundaries can already provide a repeatable and understandable image build workflow.</p>]]></content><author><name>Vikas Deolaliker</name></author><category term="Other" /><summary type="html"><![CDATA[I recently spent some time reading through my oci-image-builder repository. The project is a small web application that helps build a custom Oracle Cloud Infrastructure image starting from an existing OCI platform image, adding packages, and then handing the actual image creation off to Packer.]]></summary></entry><entry><title type="html">OCI Capacity Checker Utility</title><link href="https://vdeolali.github.io/2024/03/22/Setting-it-up.html" rel="alternate" type="text/html" title="OCI Capacity Checker Utility" /><published>2024-03-22T00:00:00+00:00</published><updated>2024-03-22T00:00:00+00:00</updated><id>https://vdeolali.github.io/2024/03/22/Setting-it-up</id><content type="html" xml:base="https://vdeolali.github.io/2024/03/22/Setting-it-up.html"><![CDATA[<p>I wanted a quick way to answer a very specific OCI question: if I need a given compute shape in a given region, can I actually get capacity there right now?</p>

<p>That is what <code>oci-cap-check</code> does. The repository is a Flask application that turns an OCI capacity-report workflow into a browser-based tool. Instead of running a long CLI flow manually every time, I can open a page on <code>localhost:5000</code>, connect to a tenancy, choose a shape and region, and get back a formatted capacity report.</p>

<h2 id="what-i-was-trying-to-solve">What I was trying to solve</h2>

<p>Capacity checks in OCI are rarely just about one value. In practice, I usually need to know:</p>

<ul>
  <li>which subscribed regions are reachable</li>
  <li>which availability domains and fault domains should be checked</li>
  <li>whether a shape needs extra configuration such as OCPUs or memory</li>
  <li>whether I am querying at tenancy level or a specific compartment</li>
  <li>whether I want the raw availability state only or the available count as well</li>
</ul>

<p>That makes even a simple “do I have capacity?” check more than a single API call.</p>

<p>The implementation in <code>oci-cap-check</code> is built around that reality. The app keeps the browser UI simple, but the backend still does the same OCI work I would otherwise script by hand.</p>

<h2 id="architecture-at-a-glance">Architecture at a glance</h2>

<p>The repository is split into a few focused modules:</p>

<ul>
  <li><code>app.py</code> handles the Flask routes and page rendering</li>
  <li><code>oci_runner.py</code> runs the reporting workflow and captures terminal-style output</li>
  <li><code>modules/identity.py</code> handles authentication, region subscription lookup, compartment selection, and AD/FD discovery</li>
  <li><code>modules/capacity.py</code> builds and submits compute capacity report requests</li>
  <li><code>templates/index.html</code> provides a two-step UI for connect-then-run</li>
</ul>

<p>That separation is useful because the web app is really a thin layer over a reusable OCI reporting flow.</p>

<h2 id="the-web-flow">The web flow</h2>

<p>The browser flow is intentionally split into two stages.</p>

<h3 id="step-1-connect-and-load-tenancy-data">Step 1: Connect and load tenancy data</h3>

<p>The first form asks for:</p>

<ul>
  <li>OCI config profile</li>
  <li>full OCI config file path</li>
</ul>

<p>The current UI defaults to config-file based authentication, which maps to <code>user_auth=cf</code>.</p>

<p>When I click <strong>Connect &amp; Load Tenancy Data</strong>, <code>app.py</code>:</p>

<ol>
  <li>calls <code>init_authentication()</code></li>
  <li>creates an OCI identity client</li>
  <li>loads the tenancy’s subscribed regions</li>
  <li>loads the available compute shapes</li>
</ol>

<p>That data is then used to populate the dropdowns for the second form.</p>

<p>This is a good design choice because it prevents the user from trying to run a capacity report before the tenancy connection is actually proven to work.</p>

<h3 id="step-2-run-the-capacity-report">Step 2: Run the capacity report</h3>

<p>Once the tenancy data is loaded, the second form appears. It lets me choose:</p>

<ul>
  <li>shape</li>
  <li>target region or all subscribed regions</li>
  <li>OCPUs for Flex shapes</li>
  <li>memory for Flex shapes</li>
  <li>optional compartment OCID</li>
  <li>tenancy-level admin mode</li>
  <li>DRCC mode</li>
</ul>

<p>Submitting that form calls <code>run_capacity_check()</code> in <code>oci_runner.py</code>, which runs the full backend workflow and returns the report as text to display in the page.</p>

<h2 id="how-authentication-works">How authentication works</h2>

<p>One of the more useful parts of the repo is the authentication logic in <code>modules/identity.py</code>.</p>

<p>The backend supports multiple OCI authentication paths:</p>

<ul>
  <li>Cloud Shell delegation token</li>
  <li>local OCI config file</li>
  <li>instance principals</li>
</ul>

<p>For the web UI, the main path is config-file authentication. The code loads the config profile from the supplied file path, validates it, builds an OCI signer, and then confirms the login by fetching tenancy information through <code>IdentityClient</code>.</p>

<p>That validation step matters. It means the app does not just trust that the file exists. It verifies that the credentials are actually usable against OCI before it proceeds.</p>

<p>If every auth method fails, the underlying module still has a retry path, which comes from the original script-oriented design. In the browser flow, the main value is that the authentication errors are surfaced clearly instead of failing later during the capacity call.</p>

<h2 id="region-handling">Region handling</h2>

<p>After authentication, the next problem is deciding what to check.</p>

<p><code>get_region_subscription_list()</code> in <code>modules/identity.py</code> handles three cases:</p>

<ul>
  <li>no region specified, which falls back to the home region</li>
  <li>a specific subscribed region</li>
  <li><code>all_regions</code>, which expands to every subscribed region in the tenancy</li>
</ul>

<p>That is then followed by <code>validate_region_connectivity()</code>, which uses a <code>ThreadPoolExecutor</code> to test region connectivity concurrently.</p>

<p>I like this part of the implementation because it avoids wasting time on a long serial check across many regions. Each region is validated by creating an identity client in that region and confirming the tenancy is reachable there. Regions that fail are reported and skipped.</p>

<p>In other words, the app distinguishes between:</p>

<ul>
  <li>regions that exist</li>
  <li>regions the tenancy is subscribed to</li>
  <li>regions that are actually reachable for the current credentials</li>
</ul>

<p>That is the right way to build a useful OCI capacity tool.</p>

<h2 id="shape-handling-and-why-it-matters">Shape handling and why it matters</h2>

<p>Compute shapes in OCI are not all configured the same way, so a capacity checker cannot treat them all uniformly.</p>

<p>That logic lives in <code>modules/capacity.py</code>.</p>

<p>The implementation handles a few important cases:</p>

<ul>
  <li>bare metal shapes</li>
  <li>standard Flex shapes</li>
  <li>DenseIO Flex shapes</li>
  <li>special handling for <code>VM.Standard.A2.Flex</code></li>
</ul>

<p>For fixed shapes and bare metal, the code can often rely on the shape defaults returned by OCI.</p>

<p>For ordinary Flex shapes, the app clamps the requested OCPUs and memory to the limits allowed by the shape metadata.</p>

<p>For DenseIO Flex shapes, the code uses predefined valid configurations. That is important because those shapes are not just “pick any OCPU and memory combination.” They need a valid mapping that also accounts for NVMe layout.</p>

<p>That extra logic is one of the places where the repository stops being a thin UI wrapper and becomes a genuinely useful implementation.</p>

<h2 id="how-the-capacity-report-is-built">How the capacity report is built</h2>

<p>The core OCI call is <code>create_compute_capacity_report()</code>.</p>

<p>For each validated region, the backend:</p>

<ol>
  <li>switches the OCI client config to that region</li>
  <li>fetches availability domains</li>
  <li>fetches fault domains inside each AD</li>
  <li>derives the correct shape configuration</li>
  <li>builds <code>CreateComputeCapacityReportDetails</code></li>
  <li>submits a capacity report request for the shape and fault domain</li>
</ol>

<p>The report is then printed in a table-style format that includes:</p>

<ul>
  <li>region</li>
  <li>availability domain</li>
  <li>fault domain</li>
  <li>shape</li>
  <li>OCPU</li>
  <li>memory</li>
  <li>availability</li>
</ul>

<p>If DRCC mode is enabled, the output also includes <code>available_count</code>.</p>

<p>That gives the app two reporting modes:</p>

<ul>
  <li>a simpler availability view</li>
  <li>a more detailed count-aware view for DRCC use cases</li>
</ul>

<h2 id="why-i-kept-the-output-text-based">Why I kept the output text-based</h2>

<p>The report display in the UI is a <code>&lt;pre&gt;</code> block, not a rich data grid. That is a pragmatic choice.</p>

<p>The backend already had a print-oriented reporting flow, so <code>oci_runner.py</code> uses <code>redirect_stdout()</code> to capture that output into an in-memory buffer and return it to the page. That lets the same operational output format work in both script and web contexts.</p>

<p>For a first implementation, that is efficient:</p>

<ul>
  <li>no need to redesign all reporting output into JSON-first rendering</li>
  <li>easy to preserve familiar script output</li>
  <li>faster to debug because the raw report remains visible</li>
</ul>

<p>If I wanted to evolve the tool later, the next step would be to return structured data and render it as a sortable HTML table. But for an operator-facing internal tool, the captured text output is a reasonable tradeoff.</p>

<h2 id="access-model-and-compartment-handling">Access model and compartment handling</h2>

<p>Another useful design point is that the app does not assume tenancy-wide admin access.</p>

<p><code>set_user_compartment()</code> in <code>modules/identity.py</code> lets the workflow run in either of these modes:</p>

<ul>
  <li>tenancy-level if the user has broader admin rights</li>
  <li>compartment-level if the user only has access to a specific compartment</li>
</ul>

<p>That matters in real OCI environments where permissions are often scoped narrowly. The app also validates compartment state before running the report, which avoids misleading failures later in the flow.</p>

<h2 id="what-i-think-works-well">What I think works well</h2>

<p>After reading through the repo, a few implementation choices stand out as solid:</p>

<ul>
  <li>The UI is split into a connection step and a reporting step.</li>
  <li>Authentication is validated early.</li>
  <li>Region connectivity is checked concurrently.</li>
  <li>Shape-specific constraints are handled explicitly instead of ignored.</li>
  <li>Availability domains and fault domains are expanded automatically.</li>
  <li>The tool supports both tenancy-level and compartment-level usage.</li>
</ul>

<p>Those are the parts that make this more than just a demo Flask form.</p>

<h2 id="what-i-would-improve-next">What I would improve next</h2>

<p>If I continue building on <code>oci-cap-check</code>, the next improvements are fairly clear:</p>

<ul>
  <li>replace the text report with structured HTML table rendering</li>
  <li>remove leftover interactive CLI prompts from shared modules when running in web mode</li>
  <li>add stronger UI validation for Flex fields</li>
  <li>let the user save common report presets</li>
  <li>export results as CSV or JSON</li>
  <li>add pagination or filtering if the result set gets large across many regions</li>
</ul>

<p>The current version already proves the main idea, though: OCI capacity checks can be wrapped in a simple browser workflow without losing the important tenancy, region, compartment, and shape logic underneath.</p>

<h2 id="final-thoughts">Final thoughts</h2>

<p><code>oci-cap-check</code> is essentially an operational script promoted into a small web application.</p>

<p>That is why the implementation works well. It did not start by chasing a fancy UI. It started with the real OCI tasks that matter:</p>

<ul>
  <li>authenticate correctly</li>
  <li>discover the right regions</li>
  <li>handle shape configuration properly</li>
  <li>query capacity across ADs and FDs</li>
  <li>show the result in a format that is easy to read</li>
</ul>

<p>For a tool meant to answer “where can I actually launch this shape?”, that is the right level of engineering.</p>]]></content><author><name>Vikas Deolaliker</name></author><category term="Other" /><summary type="html"><![CDATA[I wanted a quick way to answer a very specific OCI question: if I need a given compute shape in a given region, can I actually get capacity there right now?]]></summary></entry><entry><title type="html">OCI Maintenance Utility</title><link href="https://vdeolali.github.io/2023/10/12/FirstTest.html" rel="alternate" type="text/html" title="OCI Maintenance Utility" /><published>2023-10-12T00:00:00+00:00</published><updated>2023-10-12T00:00:00+00:00</updated><id>https://vdeolali.github.io/2023/10/12/FirstTest</id><content type="html" xml:base="https://vdeolali.github.io/2023/10/12/FirstTest.html"><![CDATA[<p>One of the repetitive operational tasks in OCI is dealing with instances that have been marked for maintenance reboot. The hard part is usually not the reboot itself. It is figuring out which instances are affected, grouping them into a rollout plan, and then executing those reboots in a controlled way.</p>

<p>That is exactly what <code>oci-maintenance</code> is built to do.</p>

<p>The repository is intentionally small. It is a single Python script, <code>maint.py</code>, backed by the OCI Python SDK. But the script captures a practical maintenance workflow:</p>

<ul>
  <li>discover all instances flagged for maintenance reboot</li>
  <li>spread them into maintenance pools</li>
  <li>reboot one pool at a time using <code>REBOOT_MIGRATE</code></li>
  <li>support dry runs before changing anything</li>
</ul>

<h2 id="what-problem-the-script-is-solving">What problem the script is solving</h2>

<p>If I have a fleet spread across multiple subscribed OCI regions, a maintenance event can affect more than one place at once. I do not want to reboot everything immediately, and I do not want to sort instance lists manually every time.</p>

<p>I want a flow that answers three questions cleanly:</p>

<ol>
  <li>Which instances are currently marked for maintenance reboot?</li>
  <li>How can I split them into batches for staged execution?</li>
  <li>How do I reboot only the batch I am ready to move?</li>
</ol>

<p>The design of <code>oci-maintenance</code> follows that exact sequence.</p>

<h2 id="structure-of-the-repository">Structure of the repository</h2>

<p>The repo is simple enough that the entire implementation lives in one file:</p>

<ul>
  <li><code>maint.py</code></li>
</ul>

<p>That script is structured around a few focused functions:</p>

<ul>
  <li><code>get_config()</code> loads OCI config and authentication details</li>
  <li><code>get_subscribed_regions()</code> discovers the tenancy’s subscribed regions</li>
  <li><code>search_instances_needing_reboot()</code> finds affected instances using OCI Resource Search</li>
  <li><code>list_instances()</code> prints the currently affected instances</li>
  <li><code>assign_pools()</code> applies a <code>maintenance_pool</code> freeform tag</li>
  <li><code>reboot_pool()</code> triggers <code>REBOOT_MIGRATE</code> for one selected pool</li>
</ul>

<p>The script is then exposed through a small CLI built with <code>argparse</code>.</p>

<h2 id="authentication-and-config-handling">Authentication and config handling</h2>

<p>The first thing the script does is load OCI credentials from a config file.</p>

<p><code>get_config()</code>:</p>

<ul>
  <li>expands <code>~</code> in the config path</li>
  <li>loads the selected profile</li>
  <li>extracts the configured <code>pass_phrase</code> if present</li>
  <li>returns both the config and the passphrase</li>
</ul>

<p>The script then passes that configuration into the OCI SDK clients. One useful detail here is that the script explicitly accounts for encrypted private keys by reading the passphrase from the OCI config profile.</p>

<p>That is a pragmatic choice. A lot of small operational scripts fail the moment the key is encrypted or the config path is not the default one. This implementation makes those assumptions visible and configurable from the CLI.</p>

<p>The CLI supports:</p>

<ul>
  <li><code>--config</code> for a custom OCI config path</li>
  <li><code>--profile</code> for a non-default profile</li>
  <li><code>--dry-run</code> for safe previews</li>
</ul>

<h2 id="how-affected-instances-are-discovered">How affected instances are discovered</h2>

<p>The core discovery step is in <code>search_instances_needing_reboot()</code>.</p>

<p>Instead of iterating through every instance with a compute list call and then filtering in code, the script uses OCI Resource Search with a structured query:</p>

<pre><code class="language-text">query instance resources where timeMaintenanceRebootDue &gt; 'Now'
</code></pre>

<p>That is a strong implementation choice because it lets OCI do the filtering. The script only receives the instances that matter for the maintenance workflow.</p>

<p>The search result gives the script the data it needs for the next steps:</p>

<ul>
  <li>display name</li>
  <li>instance OCID</li>
  <li>availability domain</li>
  <li>compartment ID</li>
  <li>freeform tags</li>
</ul>

<h2 id="multi-region-coverage">Multi-region coverage</h2>

<p>After loading credentials, the script uses <code>IdentityClient.list_region_subscriptions()</code> to retrieve all subscribed regions in the tenancy.</p>

<p>That region list is then used in each of the main workflows:</p>

<ul>
  <li>listing instances</li>
  <li>assigning pools</li>
  <li>rebooting a pool</li>
</ul>

<p>For each region, the script creates a region-specific client configuration and runs the maintenance search there.</p>

<p>That matters because maintenance actions are often distributed across regions, and the script is clearly designed to work at tenancy scope instead of assuming a single-region environment.</p>

<h2 id="listing-instances-that-need-reboot">Listing instances that need reboot</h2>

<p>The <code>list</code> command is the read-only entry point:</p>

<pre><code class="language-bash">python maint.py --profile CVM list
</code></pre>

<p><code>list_instances()</code>:</p>

<ol>
  <li>loads the OCI config</li>
  <li>gets the subscribed regions</li>
  <li>searches each region for instances where <code>timeMaintenanceRebootDue &gt; 'Now'</code></li>
  <li>collects those results into a single list</li>
  <li>prints a readable table</li>
</ol>

<p>The printed output includes:</p>

<ul>
  <li>region</li>
  <li>instance name</li>
  <li>OCID</li>
  <li>availability domain</li>
  <li>current maintenance pool tag</li>
</ul>

<p>That last value is important because it lets the listing serve two purposes:</p>

<ul>
  <li>identify maintenance candidates</li>
  <li>confirm how they are currently staged</li>
</ul>

<h2 id="assigning-maintenance-pools">Assigning maintenance pools</h2>

<p>The next operational step is controlled rollout.</p>

<p>That logic lives in <code>assign_pools()</code>.</p>

<p>The command takes a pool count:</p>

<pre><code class="language-bash">python maint.py --profile CVM --dry-run assign-pools --num-pools 3
</code></pre>

<p>The implementation does a few useful things:</p>

<ul>
  <li>gathers all affected instances across regions</li>
  <li>sorts them by OCID for deterministic ordering</li>
  <li>assigns pool numbers in round-robin fashion</li>
  <li>writes the result into the <code>maintenance_pool</code> freeform tag</li>
</ul>

<p>The round-robin assignment is simple:</p>

<pre><code class="language-python">new_pool = str((i % num_pools) + 1)
</code></pre>

<p>That means if I choose three pools, the script will distribute the affected instances across pool <code>1</code>, <code>2</code>, and <code>3</code> in a stable sequence.</p>

<p>The tagging model is also practical because it stores rollout state directly on the instance as metadata. Once the tags exist, the reboot step can use them without maintaining a separate inventory file.</p>

<h3 id="why-dry-run-matters-here">Why dry-run matters here</h3>

<p><code>assign_pools()</code> has a dry-run branch that prints the assignments without updating any tags.</p>

<p>That is exactly what I want for a maintenance script. Before changing anything, I can see:</p>

<ul>
  <li>which instances would be placed in which pool</li>
  <li>whether an instance is already in the expected pool</li>
  <li>whether the chosen pool count produces a sensible distribution</li>
</ul>

<p>For operational tooling, dry-run support is one of the highest-value safety features, and this script includes it in the right places.</p>

<h2 id="rebooting-one-pool-at-a-time">Rebooting one pool at a time</h2>

<p>Once pools are assigned, <code>reboot_pool()</code> handles the execution step.</p>

<p>Its job is:</p>

<ol>
  <li>search the affected instances again across subscribed regions</li>
  <li>filter only the instances whose <code>maintenance_pool</code> tag matches the requested pool number</li>
  <li>issue an OCI instance action of <code>REBOOT_MIGRATE</code></li>
</ol>

<p>Example usage:</p>

<pre><code class="language-bash">python maint.py --profile CVM reboot-pool --pool 2
</code></pre>

<p>Or safer:</p>

<pre><code class="language-bash">python maint.py --profile CVM --dry-run reboot-pool --pool 2
</code></pre>

<p>That workflow is a good operational pattern because it makes the reboot stage explicit and auditable. The maintenance pool tag is the handshake between planning and execution.</p>

<h2 id="the-actual-oci-action-used">The actual OCI action used</h2>

<p>The script does not perform a generic reboot. It specifically calls:</p>

<pre><code class="language-python">compute_client.instance_action(inst['ocid'], 'REBOOT_MIGRATE')
</code></pre>

<p>That matters because the whole utility is about OCI maintenance-related reboot migration, not arbitrary instance restarts.</p>

<p>So the implementation aligns with the operational intent:</p>

<ul>
  <li>discover instances flagged for maintenance</li>
  <li>use the maintenance-specific reboot action</li>
</ul>

<h2 id="error-handling-and-observability">Error handling and observability</h2>

<p>The script is direct rather than elaborate, but it does include useful debug output.</p>

<p>It prints:</p>

<ul>
  <li>config path being used</li>
  <li>profile being loaded</li>
  <li>whether a passphrase was found</li>
  <li>region-specific client initialization</li>
  <li>success or failure of update and reboot operations</li>
</ul>

<p>For small operations tooling, that is often enough. The script is not trying to be a full logging platform. It is trying to be debuggable when run by an operator.</p>

<p>It also catches <code>ServiceError</code> around the mutating OCI operations so an update or reboot failure for one instance is reported clearly.</p>

<h2 id="what-i-think-works-well-in-this-implementation">What I think works well in this implementation</h2>

<p>After reading the code, a few design choices stand out:</p>

<ul>
  <li>OCI Resource Search is used to discover only the instances that matter.</li>
  <li>Region coverage is handled automatically through subscribed region discovery.</li>
  <li>Pool assignment is deterministic because the instance list is sorted.</li>
  <li>Rollout state is stored as a freeform tag on the instance.</li>
  <li>Dry-run exists for both assignment and reboot flows.</li>
  <li>The reboot action is maintenance-specific rather than generic.</li>
</ul>

<p>That gives the script a clear operational lifecycle:</p>

<ul>
  <li>observe</li>
  <li>stage</li>
  <li>execute</li>
</ul>

<h2 id="where-i-would-extend-it-next">Where I would extend it next</h2>

<p>If I were taking this further, the next improvements would probably be:</p>

<ul>
  <li>add concurrency for tag updates and reboot actions</li>
  <li>add CSV or JSON export for the list command</li>
  <li>include compartment name in the printed report</li>
  <li>add filters for region, AD, or tag scope</li>
  <li>add a summary count per pool before execution</li>
  <li>add retry behavior or waiter logic for reboot tracking</li>
</ul>

<p>None of those are required for the current script to be useful. The existing implementation already does the main job well.</p>

<h2 id="final-thoughts">Final thoughts</h2>

<p><code>oci-maintenance</code> is a good example of a small operational utility that solves a concrete problem without overengineering it.</p>

<p>It does not try to be a full fleet management platform. Instead, it focuses on a narrow and valuable maintenance workflow:</p>

<ul>
  <li>find the instances OCI says need reboot migration</li>
  <li>group them into staged pools</li>
  <li>execute those reboots one pool at a time</li>
</ul>

<p>For a script that lives in a single file, that is a solid implementation boundary.</p>]]></content><author><name>Vikas Deolaliker</name></author><category term="Other" /><summary type="html"><![CDATA[One of the repetitive operational tasks in OCI is dealing with instances that have been marked for maintenance reboot. The hard part is usually not the reboot itself. It is figuring out which instances are affected, grouping them into a rollout plan, and then executing those reboots in a controlled way.]]></summary></entry></feed>