HTML Tables with Hidden Data: Scraping What You Can’t See

The table shows 10 columns. You export it. The CSV has 10 columns.

But the page has 15 columns of data. Where did the other 5 go?

HTML tables often contain more data than what’s visible. Hidden columns, data attributes, collapsed rows—all invisible to basic extraction methods.

Here’s how to find and extract the data you can’t see.

Types of Hidden Data

1. CSS-Hidden Columns

The simplest case: columns exist in the DOM but are hidden with CSS.

<th style="display: none;">Internal ID</th>
<td style="display: none;">12345</td>

Or via classes:

.hidden-column { display: none; }

Why sites do this: Mobile responsiveness (hide columns on small screens), internal data for JavaScript, progressive disclosure.

How to detect: Open DevTools, inspect the table, look for cells with display: none or visibility: hidden.

2. Data Attributes

HTML5 allows custom data-* attributes on any element. Tables use these to store metadata that JavaScript accesses but users don’t see.

<tr data-id="12345" data-category="electronics" data-stock="47">
  <td>Laptop</td>
  <td>$999</td>
</tr>

The visible table shows “Laptop” and “$999”. But the row carries three extra data points.

Common data attributes:

  • data-id — internal identifier
  • data-sort-value — numeric value for sorting (when display shows “Jan 2024” but sort needs 202401)
  • data-raw — unformatted value (when display shows “$1.2M” but data is 1200000)
  • data-href — link URL

3. Title and Tooltip Attributes

The title attribute provides hover text that often contains additional information.

<td title="Updated: 2024-01-15 14:32:00 UTC">Jan 15</td>

The cell displays “Jan 15”. The full timestamp is in the tooltip.

4. Collapsed/Expandable Rows

Tables with drill-down functionality hide detail rows until clicked.

<tr class="parent-row">
  <td>Category A</td>
  <td>$50,000</td>
</tr>
<tr class="child-row" style="display: none;">
  <td>— Subcategory A1</td>
  <td>$30,000</td>
</tr>
<tr class="child-row" style="display: none;">
  <td>— Subcategory A2</td>
  <td>$20,000</td>
</tr>

The data exists. It’s just not expanded.

5. Lazy-Loaded Content

Some tables show a subset and load more via JavaScript when you scroll or click “Load More.”

<table id="results">
  <!-- First 20 rows rendered -->
</table>
<button onclick="loadMore()">Show More Results</button>

The remaining data isn’t in the DOM until you trigger loading.

Extracting Hidden Data

Extracting CSS-Hidden Columns

With JavaScript (in browser console):

// Remove display:none from all cells
document.querySelectorAll('td, th').forEach(el => {
  el.style.display = '';
});

Now export normally—the columns are visible.

With Python + BeautifulSoup:

BeautifulSoup ignores CSS. It extracts all elements regardless of visibility.

from bs4 import BeautifulSoup

soup = BeautifulSoup(html, 'html.parser')
table = soup.find('table')

for row in table.find_all('tr'):
    cells = [td.get_text(strip=True) for td in row.find_all(['td', 'th'])]
    print(cells)  # Includes hidden columns

Extracting Data Attributes

JavaScript approach:

const rows = document.querySelectorAll('table tr');
const data = [];

rows.forEach(row => {
  const rowData = {
    // Visible text
    cells: Array.from(row.querySelectorAll('td')).map(td => td.textContent),
    // Data attributes
    id: row.dataset.id,
    category: row.dataset.category,
    stock: row.dataset.stock
  };
  data.push(rowData);
});

console.log(JSON.stringify(data, null, 2));

Python approach:

for row in table.find_all('tr'):
    # Get data attributes
    attrs = {k: v for k, v in row.attrs.items() if k.startswith('data-')}

    # Get cell text
    cells = [td.get_text(strip=True) for td in row.find_all('td')]

    print(f"Data: {attrs}, Cells: {cells}")

Extracting Title Attributes

const cells = document.querySelectorAll('table td');
cells.forEach(cell => {
  if (cell.title) {
    console.log(`Display: ${cell.textContent}, Full: ${cell.title}`);
  }
});

Expanding Collapsed Rows

Option 1: Click all expanders

// Find and click all expand buttons
document.querySelectorAll('.expand-btn, .toggle-row').forEach(btn => {
  btn.click();
});

// Wait for DOM to update, then export
setTimeout(() => {
  // Export logic here
}, 1000);

Option 2: Remove hidden class

document.querySelectorAll('.child-row, .detail-row').forEach(row => {
  row.style.display = '';
  row.classList.remove('hidden', 'collapsed');
});

Triggering Lazy Load

This is trickier—you need to simulate the action that triggers loading.

// Scroll to bottom to trigger infinite scroll
window.scrollTo(0, document.body.scrollHeight);

// Or click "Load More" repeatedly
const loadMore = document.querySelector('.load-more-btn');
while (loadMore && !loadMore.disabled) {
  loadMore.click();
  await new Promise(r => setTimeout(r, 500)); // Wait for load
}

Real-World Example: Sort Values

Sports statistics tables often display formatted numbers but sort by raw values.

<td data-sort="1234567">1.23M</td>
<td data-sort="20240115">Jan 15, 2024</td>
<td data-sort="0.347">34.7%</td>

If you only extract the visible text, you get formatted strings that won’t sort or calculate correctly.

Extraction with both values:

const cells = document.querySelectorAll('table td');
const data = Array.from(cells).map(cell => ({
  display: cell.textContent.trim(),
  sortValue: cell.dataset.sort || cell.textContent.trim()
}));

When Browser Extensions Help

Good table export tools handle some of this automatically:

  • CSS-hidden columns can be included via option
  • Data attributes can be extracted as additional columns
  • Number normalization can parse “$1.23M” into 1230000

HTML Table Exporter extracts visible content with proper handling of merged cells and formatting. For more complex extraction needs without code, see our guide on scraping tables from websites without code.

For data attributes and deeply hidden content, you may need the JavaScript approaches above.

The Inspection Workflow

Before extracting any table:

  1. Open DevTools (F12)
  2. Inspect the table element
  3. Check for:

    • display: none on columns
    • data-* attributes on rows/cells
    • title attributes with extra info
    • Hidden child rows
    • “Load more” buttons
  4. Decide: Is the visible data enough, or do you need the hidden data?

Most of the time, visible data is sufficient. But when it’s not, knowing how to find and extract hidden data makes the difference.

Summary

HTML tables can contain multiple layers of data:

Layer How to Access
Visible text Standard export
CSS-hidden columns Remove display:none or use BeautifulSoup
Data attributes JavaScript dataset or Python attrs
Title/tooltips Extract title attribute
Collapsed rows Expand or remove hidden class
Lazy-loaded Trigger loading first

The data is usually there. You just need to know where to look.

Need to export the data you can see? Learn more at gauchogrid.com/html-table-exporter or try HTML Table Exporter free on the Chrome Web Store.

AI Agents defeat obfuscated JavaScript in 10 minutes

Claude Code

This is a focused write-up of an experiment I ran on the AfterPack blog – the full four-paragraph prompts, the 883-line script that failed, the bytecode disassembly, the recovered source for both targets. Here I want to get to why it worked, and what that changes.

An LLM agent that can run the obfuscated code defeats it in minutes – recovered clean source from two JS obfuscators, on the vendors’ own published demo files, in 10 and 20 minutes. One of those was a commercial enterprise product whose own marketing has argued AI can’t do this; that argument is accurate about a chatbot, not about an agent that writes and runs scripts.

So I gave Claude Code two obfuscated files and one prompt each, and let it actually execute things.

Target one: a custom VM with nine defense layers

The first target: 1,587 lines, 68 KB of obfuscated output (~194× the 13-line calculatePrice(quantity, unitPrice) input – the function an open-source obfuscator publishes on its landing page to show off VM mode), recovered to source in ~10 minutes. Inside: nine composable defense layers wrapped around a ~1,500-line custom stack-based VM interpreter.

The prompt was four short paragraphs – “deobfuscate this, iterate as long as you need, write whatever temp files help, give me the closest reconstruction plus notes on the techniques” – and I never told it which obfuscator the file came from. Claude read it four times, recognized it from the structure, and wrote a six-step plan.

Claude Code's six-step plan for the VM-obfuscated output: decrypt the RC4 string array, clean the VM runtime, extract and decode the encrypted bytecode, disassemble, reconstruct, document.
Claude’s plan, written before the real work started.

The first attempt was the natural one and it failed. Claude wrote an 883-line deobfuscate.js that statically reimplemented the pipeline – RC4 string decryption, base64, the binary deserializer. It got the ~500 encrypted string calls back. It recovered the environment-fingerprint value. Then it hit a custom zigzag-varint bytecode format, picked the wrong version byte, and produced garbage. Reimplementing the deserializer from the outside was a trap.

So it stopped reimplementing and started instrumenting. A 48-line instrument2.js made a modified copy of the obfuscated file with a few logging hooks spliced into the VM, ran that copy, and let the obfuscator decode its own bytecode at runtime. Out came the function name, the parameters, the locals, the constants [0.15, 100, 1, "calculatePrice"], and the per-function keys it needed: blockKey=54, jumpKey=9643, seKey=4168320119.

The 48-line instrument2.js that patches the obfuscated VM to log its own decoded bytecode and dispatch trace, recovering the function name, params, locals, constants, blockKey, jumpKey and seKey directly from the running program.
The pivot: don’t reimplement the deserializer, let the obfuscator run it for you.

A disassemble.js took the captured 22-instruction bytecode stream, named the opcodes (PUSH_CONST, LOAD_ARG, STORE_LOCAL, MUL, SUB, GT, JMP_FALSE, RETURN…), and reconstructed:

function calculatePrice(price, quantity) {
  const taxRate = 0.15;
  const threshold = 100;
  let total = price * quantity;
  if (total > threshold) {
    total = total * (1 - taxRate);
  }
  return total;
}
console.log(calculatePrice(10, 20)); // → 170

170, same as the original, on every input I tried including the boundary case. What doesn’t survive perfectly are the names – quantity, unitPrice came back as price, quantity (argument order inferred from behavior), the two SECRET_* constants came back as taxRate and threshold – because those are inferred from how the code behaves, not literally stored. The literal values 0.15 and 100 survived untouched, because the VM needs them in the bytecode to run. Total elapsed time: about ten minutes.

Target two: a commercial enterprise obfuscator, completely different machinery

Same experiment against a commercial enterprise obfuscator (a paid product), using their own published demo – a background sprite-atlas module they ship to advertise their default protection profile. Same shape of prompt, thinking turned up this time.

Claude read the file once and had the structure mapped: a namespace registry, a URL-encoded XOR-keyed string blob, a 3D index table of opaque integer tags, a “self-replacing decoder” that primes itself for exactly eight calls and then degrades into a direct lookup, and the actual payload – a control-flow-flattened state machine building one object called BACKGROUND. None of that was in the prompt. Different primitives entirely (no bytecode VM, no RC4, no anti-debug timing), but the same six-step arc translated almost directly – and this time the instrumentation pivot wasn’t even needed. Static analysis alone took 24,620 bytes down to:

var BACKGROUND = {
  HILLS: { x: 5, y: 5, w: 1280, h: 480 },
  SKY: { x: 5, y: 495, w: 1280, h: 480 },
  TREES: { x: 5, y: 985, w: 1280, h: 480 },
};

The recovered source for the commercial obfuscator's demo: five lines defining var BACKGROUND = { HILLS, SKY, TREES } sprite-atlas geometry, verified against both the static reconstruction and a patched dynamic run.
24 KB in, five lines out, about twenty minutes.

Why it didn’t hold up

Four structural reasons, and they’re the interesting part because they predict what does and doesn’t work next.

1. The inverse has to live in the bundle. Every defense in this family ships its own undo – the decoder for the encrypted string, the shuffle key for the opcode table, the source values for the environment fingerprint – because if the inverse weren’t there, the program couldn’t run. That makes the whole family recoverable in principle. The only real question is cost.

2. Sequential transforms unroll one at a time. When layers are composed as t₁ ∘ t₂ ∘ … ∘ tₙ, the inverse is just the reverse composition. LLMs are fluent at recognizing and inverting individual transform families – string-array rotation, RC4, base64, XOR-with-constant, seeded Fisher-Yates – because they’ve seen thousands of each. Defeating n layers takes roughly n units of work, not the 2ⁿ you’d get from genuinely interleaved transforms.

3. A VM is a single point of failure. The logical program is in custom bytecode, not JavaScript – but the thing that turns bytecode into behavior, the interpreter, sits right there in the bundle. Instrument its dispatch loop once and every function it will ever run is decoded. The anti-debug timing checks only fire when a human is stepping slowly with a breakpoint; automated instrumentation runs at full speed and never trips them.

4. The skill floor moved. None of this was unbreakable before LLMs – there are decade-old papers on reversing control-flow flattening by hand. What changed is who can do it and how fast. “Expert reverse-engineer with tooling, give it a few days” became “any engineer with an API key, give it a coffee break”, and the original names – which used to be the one thing reverse-engineers had to invent from scratch – now come back from semantic context for free. Automated deobfuscators like webcrack and ben-sb/javascript-deobfuscator already handled the static layers; HumanifyJS showed an LLM could put readable names back. An agent that also runs the code closes the gap on the VM-and-anti-debug end. This is the same direction Google went with CASCADE – a production LLM deobfuscator – and the JsDeObsBench work, and the same trade-off Elastic’s security team and the RSAC piece on the topic have been writing about. The honest caveat, which they keep raising and I’ll second: LLM-recovered code can be confidently wrong, so you verify.

The nine layers from the first run, and why each one came apart:

Layer What it does Why it didn’t hold
1. String array + RC4 All ~500 string literals encrypted, decoded at runtime via U(idx, key) Self-contained – evaluate U() once, statically decrypt every call
2. String array rotation The array is rotated by an IIFE until a checksum matches Runs on startup. Let it run, read the rotated array
3. Environment fingerprinting (h()) XORs 0x5f3759df with built-in .length values Those values are identical on every standard JS engine. No real binding
4. Bytecode encryption (RC4) Bytecode blob encrypted with a key derived from h() Once h() is evaluated, decryption is one line
5. Binary serialization (B()) Custom zigzag-varint format with flag-gated conditional fields Don’t reimplement it – instrument it. Let the obfuscator decode itself
6. Opcode shuffling (b3 table + per-function seed) Maps logical opcodes to shuffled indices Seeded PRNG. Reconstructible once you have the seed and the algorithm
7. Operand XOR (blockKey, jumpKey) Every opcode and jump target XORed with a per-function key Simple XOR. One known-plaintext recovers the key
8. Stack value encryption (seKey) Integer values XORed with a key when pushed to the VM stack Applied only to integers. Floats, strings, objects stored in plaintext
9. Anti-debug timing + VM interpreter Timing checks corrupt the opcode table if stepped; ~1,500-line custom VM Instrumentation runs full-speed. The VM is the inverse – instrument dispatch once, get everything

What this actually puts at risk

The case I keep coming back to is the one where the constants and the control flow are the IP: a license-check function, a trial-vs-paid gate, a token validator that decides whether the user gets the feature. The entire defense there is “make it expensive to read”, and the cost of reading it just dropped by a couple of orders of magnitude. Trading-strategy code, ranking logic, anti-fraud heuristics, recommendation algorithms – anything that’s a function shipped to the browser is in the same bracket. So is browser-game anti-cheat and DRM, where the whole threat model assumed days of attacker work per layer; cheat developers can now iterate inside the patch cycle instead of weeks behind it. (I haven’t seen public evidence of LLM-driven cheat development yet – but the cost gradient is pointing the wrong way for defenders.)

A lot of obfuscation purchase decisions were priced on an attacker who needed days of expert work per layer. The defenses that used to buy a year of headroom against the kind of attacker who could be bothered now buy an afternoon against anyone with a Claude account.

What I haven’t tested, and where I land

Two products, two demo files, both picked by their vendors to advertise their defaults. What I haven’t touched is the paid stuff sitting on top: environment-bound execution locks, self-defending integrity checks, anti-debugging traps, domain-bound execution gates – each of those adds a real obstacle. And transforms that scatter and entangle the inverses across the bundle so they aren’t separable into peelable layers – are a separate experiment. Both deserve a follow-up.

But for the default profiles of mainstream JS obfuscators, including a commercial one, I think the structural claim holds: they don’t raise the cost of recovery enough to matter against a capable 2026 LLM. (I wrote about an adjacent version of this – that minified code was never really hidden either – a few weeks ago.) And yes, I’m building a modern obfuscator at AfterPack on the other premise: transforms in Rust where the inverses are scattered and entangled across the bundle deeply enough that recovery becomes combinatorial (n^m) rather than linear. JavaScript still runs – the inverses are still there – but you can’t peel them out one layer at a time. That’s the only answer I currently believe in, and I’m aware “I’m building the alternative” is exactly what you’d expect me to say – which is why the prompts and the recovered code are all in the original post, so you can run it yourself.

If you ship obfuscated JavaScript today, that’s the experiment to run before you find out the hard way what’s in it.

Originally published on afterpack.dev. I’m Nikita Savchenko – I build production SaaS and write about Anthropic‘s tools, JavaScript security, and the things I break on purpose.

Think with your second brain: a proper Claude Code harness for Obsidian

Difference between RAG and an agentic harness for context retrieval

An agentic doc harness is a set of Claude Code skills that turn an Obsidian vault into a structured workspace, letting an LLM walk wikilinks the way a coding agent walks imports — without vector-RAG retrieval. On a 99-note evaluation vault it beat a vector-RAG baseline on faithfulness +0.27, grounding +0.80, insight novelty +1.00, answer relevancy +0.40 (Claude-as-judge, 0–3 scale).

Repo: github.com/nickyeolk/agentic_doc_harness

Why I built it

I used to have OneNote as my main note keeping app, supplemented by Google Keep for quick notes. As my notes grew, and OneNote’s android app withered, I experienced two problems: OneNote was way too slow, and there was no easy way for an AI agent to organically plug into it.
The idea was to make use of a coding agent’s capability to understand code, and use it to understand notes instead.
I consolidated years of notes from OneNote into Obsidian. It worked ok-ish at first, but then I quickly started to encounter limits to the way claude grepped and grokked its way through my ‘notebase’. Coding agents depend on code structure to jump from object to object, this did not exist in my disjointed notes.

Vector RAG vs vault harness

Difference between RAG and an agentic harness for context retrieval

Same input, same model, different navigation.

Vector RAG embeds the vault, retrieves k chunks by cosine similarity, hands them to the LLM. Chunks arrive as sentence-level fragments with no provenance.

The harness reads VAULT_INDEX.md (a generated map of the vault), routes to an entry note, walks outbound wikilinks, surfaces a few topically-similar but unlinked notes. Notes arrive whole, in their original structure.

Harness structure

Four Claude Code skills plus a small set of generated files.

The entire architecture of the harness skills

/harness-init (one-time). Walks the vault, classifies sections, detects hub-candidate notes by inbound mention count, asks 5 clarifying questions, generates VAULT_INDEX.md (the map), root and per-section CLAUDE.md files (orientation), and a small config.

/vault-discover (graph builder). Four modes. Mode 1 ranks notes by inbound mention frequency to surface hub candidates. Mode 2 finds every unlinked mention of a hub and proposes adding [[wikilinks]]. Mode 3 detects orphan notes and classifies them. Mode 4 groups by shared vocabulary to find clusters that need a MOC.

vault-context (runtime navigator). Used every session. Depth Mode for directed tasks (1–2 hops). Synthesis Mode for cross-domain queries (multi-hub traversal). Hybrid Mode (opt-in) layers in embedding-aware filtering when Smart Connections is installed.

/obsidian-tooling (optional). Installs the Smart Connections plugin and pre-configures it. The harness then reads .smart-env/multi/*.ajson directly to use embeddings without any Python ML dependency.

The harness instructs Claude about the vault. It never prescribes what’s in the vault.

Eval

Synthetic vault: 99 notes representing a fictional marketing consultant pursuing an MS Marketing degree with a family. Five folders. Generator emits zero wikilinks — flat import state.

Baseline: LlamaIndex vector RAG with nomic-embed-text.

Harness: pure wikilink traversal first, Hybrid Mode after one iteration.

15 synthesis tasks (single-domain, cross-domain, trap queries). Each response scored by a Claude Sonnet judge on faithfulness, grounding, insight novelty, answer relevancy (0–3 each).

First pass, pure wikilink traversal:

Dimension Baseline Harness (pure)
Faithfulness 2.067 2.000
Grounding 2.133 2.533
Insight novelty 1.533 2.333
Answer relevancy 2.067 2.400

Three wins, one loss. Faithfulness regressed below baseline. Diagnosis: wikilink traversal is query-agnostic. From a Studies entry note, the agent followed a link to a Clients note even when the query was strictly about coursework. Cross-domain contamination.

Hybrid Mode

Embedding the query at runtime would have required a Python ML stack. I wanted to avoid that.

Smart Connections (Obsidian plugin) already maintains an embedding cache on every note save. The harness reads it.

Move one: filter wikilinks by anchor similarity. For each outbound link, compute cosine(entry_note_embedding, candidate_embedding). Drop links below threshold. A Studies-to-Studies link scores 0.78–0.80. Studies-to-Clients scores 0.68. Studies-to-Family scores 0.57. Threshold becomes a tunable filter.

Move two: orphan surfacing. After traversal, take top-k notes vault-wide by similarity to the entry note. Drop anything already loaded. Surface up to 5. These are notes the wikilink graph never reaches but the embedding flags as topical.

Both moves use Python stdlib only. About 150 lines to walk .smart-env/‘s .ajson files and compute cosine similarity over embeddings that already exist.

Final results

Threshold sweep at {0.55, 0.60, 0.65, 0.70, 0.75}. Orphan surfacing on. Pareto-optimal at t=0.65, orphan-k=5.

Variant Faith Ground Novel Relev Notes loaded
Baseline (vector RAG) 2.067 2.133 1.533 2.067 n/a
Pure traversal 2.000 2.533 2.333 2.400 8.9
Hybrid t=0.65 +orph5 2.333 2.933 2.533 2.467 11.5
Hybrid t=0.75 +orph5 2.333 2.800 2.667 2.533 9.3

Every Hybrid variant beats baseline AND pure-traversal on every dimension. Per-query latency ~8 seconds (same as pure traversal). Notes loaded up 30%, still within token budget.

Unexpected finding: stricter wikilink filtering plus orphan surfacing beats permissive filtering. At t=0.75 most wikilinks get cut, the surfaced orphans fill the gap, insight novelty peaks. Orphan surfacing is doing more work than the wikilink filter.

This might change over time as your links build up, or if you have an extremely structured note structure.

Where this fits in the bigger picture

The approach is not entirely novel. It sits inside the confluence of a numbr of trends.

Anthropic dropped vector RAG from Claude Code in favor of agentic search. Boris Cherny, Claude Code lead: “Early Claude Code used RAG + a local vector DB, but in the end, we found agentic search to be overwhelmingly better” (Pragmatic Engineer interview, HN confirmation). Claude Code now uses Glob, Grep, Read to navigate the way a developer does. The harness extends this pattern from code to prose.

Karpathy’s LLM Wiki pattern (gist) describes keeping knowledge as markdown and skipping retrieval infrastructure entirely. Multiple open-source implementations exist: LLM Wiki Compiler, obsidian-llm-wiki-local, nashsu/llm_wiki, Ar9av/obsidian-wiki. The harness shares the anti-RAG stance and traverses progressively rather than dumping the whole corpus into context.

Microsoft’s GraphRAG (blog, arXiv 2404.16130) builds a knowledge graph from a text corpus, then uses the graph for sensemaking queries. The harness uses the graph the user already built.

Smart Connections (repo) is the dominant Obsidian-AI plugin and does RAG over the vault. The harness uses Smart Connections’ embedding cache as a secondary signal in Hybrid Mode, not as the primary retrieval mechanism.

The contribution here is synthesis: applying the agentic-search pattern to personal knowledge already structured by the user, with the wikilink graph as the first-class navigation primitive, validated against a vector-RAG baseline with concrete numbers.

Limitations

Eval is on a synthetic vault. A real personal vault will surface failure modes the synthetic one does not.

Path 1 anchor scoring uses the entry note’s embedding, not the query’s. When the entry note is broad and the query is narrow, the anchor does not pick up query intent. A future iteration may add a small local query embedder.

Entry-point selection runs on routing heuristics generated from the vault. Fragile on first contact with a new vault.

FAQ

Q: Why not just use Smart Connections?
A: Smart Connections is RAG over the vault — embed, retrieve k chunks, chat. Loses the structure. The harness uses the structure first, embeddings second.

Q: Does this work without Smart Connections?
A: Yes. Depth Mode and Synthesis Mode work on wikilinks alone. Hybrid Mode is the opt-in layer that adds Smart Connections.

Q: Does the harness modify my notes?
A: /vault-discover Mode 2 adds [[wikilinks]] to your notes, with every change shown before writing. The runtime navigator never writes.

Q: What if my vault has no wikilinks yet?
A: The harness handles that case. Designed for the OneNote / Notion / Evernote migration scenario. Mode 1 ranks hub candidates by inbound mention frequency. Mode 2 wires them in.

Q: How does this differ from Karpathy’s LLM Wiki?
A: LLM Wiki dumps the full knowledge base into the model’s context and trusts long context. The harness traverses progressively from an entry point. For vaults larger than the context window, this matters; for small vaults the two converge.

Q: Why not embed the query at runtime?
A: Would require a Python ML stack. The harness is stdlib-only by design. Hybrid Mode achieves most of the win using anchor-based scoring (entry note as the anchor) instead.

Q: What if I want to use this on an existing vault with thousands of notes?
A: It should work — vault-context is bounded by MAX_NOTES_PER_QUERY (default 12). The harness scales with the entry-point routing, not with vault size. The eval is on 99 notes; larger vaults are untested.

Links

Aspect-Oriented Programming for AI Agents: Hookflows as an Event Bus

The Pattern That Made Me Say “That’s Exactly What AOP Is”

I was debugging a notification problem in my 53-agent home assistant when I stumbled onto something unexpectedly powerful. I needed every agent dispatch to notify me via Telegram — but I didn’t want to burn tokens on a separate telegram_send_message call. The agents were already being validated by a governance hookflow. Why not piggyback the notification onto the validation step?

One tool call. Validation and notification. Zero additional tokens consumed by the agent.

Then it hit me: I’d accidentally reinvented aspect-oriented programming — but for AI agents instead of Java classes.

What Is AOP, and Why Does It Matter Here?

Aspect-oriented programming emerged in the late 1990s to solve a specific problem: cross-cutting concerns. Logging, security checks, transaction management — these behaviors cut across every module in your application, but they don’t belong in any single module’s core logic.

The AOP solution: define these concerns once, then weave them into your code at specific join points (method calls, property access) using advice (before, after, around). The original code never knows it’s being augmented.

Now apply that mental model to AI agents:

AOP Concept Agent Equivalent
Join point Tool call (e.g., task, edit, create)
Pointcut Hook trigger rule (which tools, which args)
Advice Hook step logic (validate, notify, log)
Aspect A hookflow YAML definition
Weaving The hook engine intercepting tool calls at runtime

The agent doesn’t know. It just calls a tool. The governance layer intercepts, validates, and fires side effects. This is textbook AOP — applied to a fundamentally new domain.

The Real Implementation: Enforcement-Triggered Side Effects

Here’s the actual hookflow running in my platform. It requires every agent dispatch to include a notification tag, validates it, and then sends the notification as a side effect:

name: Require task or write_agent originator notify
description: >
  Blocks task/write_agent calls unless they contain a valid
  originator_notify tag. On success, sends the parsed message
  to the originator via Telegram Bot API.
on:
  hooks:
    types: [preToolUse]
    tools: [task, write_agent]
blocking: true
env:
  TOOL_NAME: ${{ event.tool.name }}
  TASK_PROMPT_JSON: ${{ toJSON(event.tool.args.prompt) }}
  WRITE_AGENT_MESSAGE_JSON: ${{ toJSON(event.tool.args.message) }}
steps:
  - name: Validate and send originator notification
    run: |
      # Determine which tool arg contains the text
      $text = if ($env:TOOL_NAME -eq 'write_agent') {
        $env:WRITE_AGENT_MESSAGE_JSON | ConvertFrom-Json
      } else {
        $env:TASK_PROMPT_JSON | ConvertFrom-Json
      }

      # Parse the XML tag from tool arguments
      $pattern = '<originator_notifyb(?<attrs>[^>]*)>(?<message>[sS]*?)</originator_notify>'
      $matches = [regex]::Matches($text, $pattern)

      if ($matches.Count -eq 0) {
        Write-Error "Missing <originator_notify> block"
        exit 1  # BLOCKS the tool call
      }

      # Extract telegram_id via nested regex on attributes
      $attrs = $matches[0].Groups['attrs'].Value
      $idMatch = [regex]::Match($attrs, 'telegram_id=["x27](?<id>d+)["x27]')
      $telegramId = $idMatch.Groups['id'].Value

      $notifyMessage = $matches[0].Groups['message'].Value.Trim()

      # Side effect: send Telegram notification
      $botToken = $env:TELEGRAM_BOT_TOKEN
      $body = @{ chat_id = $telegramId; text = $notifyMessage } | ConvertTo-Json
      Invoke-RestMethod -Uri "https://api.telegram.org/bot$botToken/sendMessage" `
        -Method Post -ContentType 'application/json' -Body $body

The agent’s perspective? It’s just including metadata in its prompt — a governance requirement. It has no idea that including that tag triggers a Telegram message. The hookflow handles both enforcement (blocking if the tag is missing) and a side effect (sending the notification) in a single interception.

Why This Is Better Than Explicit Tool Calls

The naive approach is straightforward: after dispatching a sub-agent, call telegram_send_message explicitly. But that approach has serious problems in production multi-agent systems:

Token cost compounds. Every tool call consumes tokens — the call itself, the response, the reasoning about the response. In a system dispatching dozens of agents per hour, those extra calls add up fast.

Agent discretion is unreliable. Agents skip steps. They forget. They decide a notification “isn’t necessary this time.” When notifications are a side effect of governance, they happen deterministically. Every single time. No exceptions.

Composability degrades. When you want to add a second cross-cutting concern — say, audit logging — you’d need to update every agent’s instructions. With hookflows, you stack another aspect. The agents remain untouched.

The Composability Advantage

This pattern isn’t limited to notifications. Here are enforcement-triggered side effects I’m now implementing across the platform:

Task creation with auto-notification:
A hookflow on add_task validates the task structure, then parses a notify block to Telegram the assignee. The agent creating the task doesn’t need to know who gets notified or how.

File edits with watch triggers:
A hookflow on edit for certain file paths validates the change, then queues a test run. The editing agent doesn’t know it just triggered CI.

Content creation with publish intent:
A hookflow on create for content files validates frontmatter, then notifies the content scheduler to slot the piece. The writing agent doesn’t manage scheduling.

Each hookflow is independent. Stack them. Compose them. The agent sees one tool call; the platform executes an entire workflow.

Prior Art: I’m Not the First, But the Domain Is New

The broader software world has been exploring this territory:

Spring AI’s Advisor system draws an explicit parallel to Spring AOP — intercepting and enhancing AI calls with logging, memory injection, and retrieval augmentation. CrewAI’s LLM Call Hooks expose before/after interception points for inspection, approval gates, and response transformation. Victor Dibia’s analysis of agent middleware frames middleware as a control and observability mechanism for agent execution loops.

Microsoft’s own Agent Governance Toolkit provides application-level policy enforcement for autonomous agents via Python middleware.

What’s different about the hookflow approach is the enforcement-plus-side-effect fusion. Most prior art treats governance (blocking/allowing) and side effects (notifications/logging) as separate middleware layers. The hookflow pattern combines them: the same rule that enforces compliance also triggers downstream actions. One interception, multiple outcomes.

The Technical Architecture

The engine powering this is gh-hookflow — a Go-based workflow engine that intercepts GitHub Copilot CLI tool calls using preToolUse and postToolUse triggers. Hookflows are defined in YAML (GitHub Actions syntax) and live in .github/hookflows/. I’ve written about the governance layer before in Stop Trusting AI Agents with Git and the broader three-layer architecture that makes autonomous agents production-ready.

The execution model:

  1. Agent calls a tool (e.g., task)
  2. Hook engine intercepts via preToolUse
  3. Hookflow steps execute: parse args, validate, fire side effects
  4. If validation fails → tool call is blocked (agent sees denial message)
  5. If validation passes → tool call proceeds, side effects already fired

This is deterministic. It runs on every tool call matching the trigger. The agent cannot bypass it — unlike instructions, which are suggestions the model can ignore.

Why Token Efficiency Matters at Scale

In a system running 53 agents with scheduled cron jobs, every unnecessary tool call matters. Here’s the math:

  • Each telegram_send_message call: ~200 tokens (call + response + reasoning)
  • Agent dispatches per day: ~80-120
  • Tokens saved by hookflow side effects: 16,000-24,000 tokens/day
  • At current model pricing, that’s $0.15-0.75/day depending on model tier — which compounds across every cross-cutting concern you’d otherwise implement as explicit tool calls

But the bigger win isn’t cost. It’s reliability. Those 80-120 notifications now happen with 100% certainty. Not “usually” or “when the agent remembers.” Every time.

Building Your Own Enforcement-Triggered Side Effects

If you’re building with GitHub Copilot CLI extensions or any hook-based agent framework, here’s the pattern:

  1. Identify a cross-cutting concern — something that should happen on every tool call of a certain type
  2. Require metadata — make the agent include structured data (XML, JSON, YAML) in its tool arguments
  3. Validate the metadata — block the call if it’s missing or malformed
  4. Fire the side effect — parse the metadata and trigger your downstream action
  5. The agent never needs to know — it thinks it’s satisfying a governance requirement

The key insight: by framing side effects as governance requirements, you get both compliance enforcement AND automated workflows from a single hook. The agent is incentivized to include the metadata (because the call fails without it), and the platform gets free automation.

The Bottom Line

Aspect-oriented programming solved cross-cutting concerns in enterprise Java twenty years ago. The same pattern — interception at defined join points, transparent augmentation, composition of independent aspects — solves cross-cutting concerns in autonomous AI agent systems today.

The difference is that agents can’t be refactored to call the right methods. They’re non-deterministic. They forget. They improvise. That’s exactly why enforcement-triggered side effects are more powerful than explicit tool calls: you remove agent discretion from the equation entirely.

One tool call. Governance satisfied. Side effects fired. Zero extra tokens. That’s AOP for AI agents.

Asynchronous JavaScript

When we write JavaScript or TypeScript, most of our code runs from top to bottom, line by line. This is what we usually think of as synchronous code. One line runs, then the next, then the next.

But when we need to get information from a server, things are different. We cannot just freeze the whole application while we wait for that data to come back. If a website or app did that, most users would probably leave before anything loaded.

So JavaScript needed a way to let certain tasks happen in the background while the rest of the application kept running. That way, we can show loading states, keep the page interactive, and update the UI once the data finally returns.

This is where asynchronous JavaScript comes in. It gives us a way to start a task now and deal with the result later without blocking the rest of our code. Over time, JavaScript introduced different ways to handle this, starting with callbacks and XMLHttpRequest, then moving to promises, the Fetch API, and finally async/await.

In this post, I am going to walk through that progression and show how asynchronous JavaScript evolved into what we use today.

Callbacks and XMLHttpRequest

Before promises and async/await, the main way to deal with asynchronous code was with callback functions.

If you have not come across them before, a callback is just a function passed into another function as an argument. That function gets called later once the task is complete.

Back then, one of the main tools used to communicate with servers in the browser was XMLHttpRequest, usually shortened to XHR. Despite the name, it was not only used for XML. It could also be used to work with JSON, which is what we use most often today. Before fetch() became the normal choice, XHR was the standard way to make HTTP requests in the browser.

Here is a simple example:

function getData(callback) {
  var xhr = new XMLHttpRequest();

  xhr.onreadystatechange = function () {
    if (xhr.readyState === 4) {
      if (xhr.status >= 200 && xhr.status < 300) {
        callback(null, xhr.responseText);
      } else {
        callback("HTTP error: " + xhr.status);
      }
    }
  };

  xhr.open("GET", "/api/data", true);
  xhr.send();
}

function renderContent(data) {
  var container = document.getElementById("content");
  container.innerHTML = "<p>" + data + "</p>";
}

function showError(error) {
  var container = document.getElementById("content");
  container.innerHTML = "<p>" + error + "</p>";
}

getData(function (error, data) {
  if (error) {
    showError(error);
    return;
  }

  renderContent(data);
});

Let’s break down what is happening here.

First, we create a function called getData that accepts a callback. Inside it, we create an XMLHttpRequest object, which handles the request to the server.

Then we use xhr.onreadystatechange, which runs every time the request changes state. We check for xhr.readyState === 4, which means the request has finished. Once it has, we check the HTTP status code. If it is in the 200 range, the request succeeded, so we pass the response into the callback. If it failed, we pass back an error instead.

After that, we call xhr.open() to configure the request and xhr.send() to send it.

At the bottom, we call getData() and pass in another function. That callback decides what to do next. If there is an error, we show it. If not, we render the returned data on the page.

This worked, and for simple requests it was fine. But the problem started when multiple asynchronous tasks depended on each other.

Imagine needing to fetch a user, then fetch their posts, then fetch the comments for one of those posts. Every step depends on the previous one, which means the callbacks start getting nested inside each other. That is what became known as callback hell.

Here is a simple example of that:

function getUser(userId, callback) {
  setTimeout(function () {
    console.log("Fetched user");
    callback(null, { id: userId, name: "Alice" });
  }, 500);
}

function getPosts(userId, callback) {
  setTimeout(function () {
    console.log("Fetched posts");
    callback(null, [
      { id: 1, title: "First Post", userId: userId },
      { id: 2, title: "Second Post", userId: userId },
    ]);
  }, 500);
}

function getComments(postId, callback) {
  setTimeout(function () {
    console.log("Fetched comments");
    callback(null, [
      { id: 1, text: "Nice post", postId: postId },
      { id: 2, text: "Thanks for sharing", postId: postId },
    ]);
  }, 500);
}

getUser(42, function (err, user) {
  if (err) {
    console.error("Error fetching user:", err);
  } else {
    getPosts(user.id, function (err, posts) {
      if (err) {
        console.error("Error fetching posts:", err);
      } else {
        getComments(posts[0].id, function (err, comments) {
          if (err) {
            console.error("Error fetching comments:", err);
          } else {
            console.log("User:", user);
            console.log("Posts:", posts);
            console.log("Comments for first post:", comments);
          }
        });
      }
    });
  }
});

Even in this small example, the code is already getting deeper, more repetitive, and harder to scan. As the number of requests grows, debugging and maintaining it becomes more of a hassle.

Developers needed a cleaner way to deal with async code.

That is where promises came in.

Promises

Promises gave JavaScript a much cleaner way to handle asynchronous work.

A promise is an object that represents a value we do not have yet, but expect to receive later. That future result can either succeed or fail.

A simple way to think about it is lending a pen to someone. They promise they will give it back. You do not know exactly when that will happen, and there is always a chance they come back without it. But instead of standing there waiting, you go and do other things. Then, once the result comes back, you deal with it.

That is basically how promises work in JavaScript.

A promise can end in two main ways:

  • resolved, meaning the operation succeeded
  • rejected, meaning something went wrong

We usually handle those outcomes with .then() and .catch():

  • .then() handles the resolved value
  • .catch() handles errors or rejections
    Here is an example of wrapping XHR in a promise:
function apiGet(url) {
  return new Promise(function (resolve, reject) {
    var xhr = new XMLHttpRequest();

    xhr.onreadystatechange = function () {
      if (xhr.readyState === 4) {
        if (xhr.status >= 200 && xhr.status < 300) {
          var data = JSON.parse(xhr.responseText);
          resolve(data);
        } else {
          reject(new Error("Request failed with status " + xhr.status));
        }
      }
    };

    xhr.open("GET", url, true);
    xhr.send();
  });
}

apiGet("/api/user/42")
  .then(function (user) {
    console.log("User loaded:", user);
  })
  .catch(function (err) {
    console.error("Error:", err);
  });

Here, instead of accepting a callback, apiGet() returns a promise. Inside that promise, we call resolve() when the request succeeds and reject() when it fails.

This means the code that calls apiGet() can decide how to handle the result. That is a big improvement because it separates the request logic from the result-handling logic.

It also gave us a cleaner way to chain async steps together.

function getUser(userId) {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({ id: userId, name: "Alice" });
    }, 500);
  });
}

function getPosts(userId) {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve([
        { id: 1, title: "First Post", userId: userId },
        { id: 2, title: "Second Post", userId: userId },
      ]);
    }, 500);
  });
}

function getComments(postId) {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve([
        { id: 1, text: "Nice post", postId: postId },
        { id: 2, text: "Thanks for sharing", postId: postId },
      ]);
    }, 500);
  });
}

getUser(42)
  .then((user) => {
    console.log("User:", user);
    return getPosts(user.id);
  })
  .then((posts) => {
    console.log("Posts:", posts);
    return getComments(posts[0].id);
  })
  .then((comments) => {
    console.log("Comments:", comments);
  })
  .catch((error) => {
    console.error("Something went wrong:", error);
  });

This is still asynchronous, but it is much flatter than the callback version. It reads in a more natural order and is easier to debug.

The Fetch API

As promises became more common, browsers introduced a newer and cleaner way to make HTTP requests called the Fetch API.

The Fetch API is built around promises, which means it works naturally with .then() and .catch(). It quickly became the more modern replacement for XHR and is now what most frontend developers use by default in the browser.

Here is a simple example:

fetch("https://api.example.com/data")
  .then((response) => {
    if (!response.ok) {
      throw new Error(`HTTP error: ${response.status}`);
    }
    return response.json();
  })
  .then((data) => console.log(data))
  .catch((error) => console.error(error));

Let’s break it down.

First, fetch() sends the request and returns a promise.

When that promise resolves, we get back a response object. That is not the final JSON yet, it is the full HTTP response, so the first thing we often do is check response.ok.

If the request failed, we throw an error so that .catch() can handle it.

If the request succeeded, we call response.json(). That also returns a promise, which is why we need another .then() after it. Once that promise resolves, we finally have access to the parsed data.

Compared to XHR, this is much shorter and easier to follow. We no longer need to manage readyState, create request objects manually, or put all our logic inside event handlers.

But even with fetch and promises, long chains of .then() could still become difficult to read in more complex applications.

That is where async/await made things even cleaner.

Async Await

async/await gave us a way to write asynchronous code that looks much closer to normal synchronous code.

It is important to note that async/await is still built on promises. It does not replace them underneath, it just gives us a nicer syntax to work with them.

There are two main parts:

  • async marks a function as asynchronous
  • await pauses that function until a promise settles

Here is a basic example using fetch():

async function getData() {
  try {
    const response = await fetch("https://api.example.com/data");

    if (!response.ok) {
      throw new Error(`HTTP error: ${response.status}`);
    }

    const data = await response.json();
    console.log(data);
  } catch (error) {
    console.error(error);
  }
}

getData();

Here’s what is happening.

First, we add the async keyword to the function. That tells JavaScript we want to use await inside it.

Then we use await with fetch(). Instead of chaining .then(), we store the resolved response in a variable.

After that, we check whether the request succeeded.

Then we use await again with response.json() because parsing the response body is also asynchronous.

Finally, we wrap everything in a try…catch block, which lets us handle errors in one place.

This is one of the reasons async/await became so popular. It is still asynchronous, but it reads more like normal top-to-bottom code.

Here is the earlier user-posts-comments example rewritten using async/await:

function getUser(userId) {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({ id: userId, name: "Alice" });
    }, 500);
  });
}

function getPosts(userId) {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve([
        { id: 1, title: "First Post", userId: userId },
        { id: 2, title: "Second Post", userId: userId },
      ]);
    }, 500);
  });
}

function getComments(postId) {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve([
        { id: 1, text: "Nice post", postId: postId },
        { id: 2, text: "Thanks for sharing", postId: postId },
      ]);
    }, 500);
  });
}

async function loadUserData() {
  try {
    const user = await getUser(42);
    console.log("User:", user);

    const posts = await getPosts(user.id);
    console.log("Posts:", posts);

    const comments = await getComments(posts[0].id);
    console.log("Comments for first post:", comments);
  } catch (error) {
    console.error("Error loading data:", error);
  }
}

loadUserData();

This version is much easier to read because the steps happen in a clear order:

  • get the user
  • get the posts
  • get the comments

No deep nesting, no long promise chain, and much less repeated error handling.

One important thing to remember is that await only pauses the async function it is inside. It does not block the whole browser or freeze the rest of your application.

That is the key difference. It looks synchronous, but it still behaves asynchronously under the hood.

Conclusion

Asynchronous JavaScript has come a long way.

We started with callbacks and XMLHttpRequest, which worked but quickly became messy when multiple requests depended on each other. Then promises gave us a cleaner and more structured way to manage async code. After that, the Fetch API made browser requests easier to write, and finally, async/await made asynchronous code feel far more natural to read.

At the end of the day, all of these approaches are solving the same problem: letting JavaScript keep running while waiting for something else to finish.

If you are learning JavaScript today, async/await is probably what you will use most often. But understanding callbacks, promises, and fetch is still really useful, because it helps you understand what is happening underneath and why modern async code looks the way it does.

If you want to learn more about JavaScript, check out:

GitHub Just Shipped What I Built 2 Months Ago — And That’s a Good Thing

The Pattern Is Undeniable Now

On May 18, GitHub made remote control for Copilot CLI sessions generally available — on mobile, web, and VS Code. You start a session on your workstation, scan a QR code, and steer your agent from your phone while walking the dog.

I published a 3,000-word guide to doing exactly this via Telegram on April 11. Same core concept: your AI agent runs on your machine, you interact with it from your pocket. Different implementation, identical insight.

This isn’t an “I told you so” moment. This is a validation moment. When the team building the tool arrives at the same architectural conclusion you reached independently — mobile-first agent interaction isn’t optional, it’s inevitable — that tells you something important about where this industry is headed.

What GitHub Shipped

The feature dropped in public preview on April 13 and hit GA on May 18. Here’s the core workflow:

  1. Start a session: copilot --remote
  2. The CLI displays a link and QR code
  3. Open it in the GitHub Mobile app or any browser
  4. Your session streams in real time — you can steer, approve permissions, send follow-up prompts, switch modes, or stop execution entirely

The GA release expanded the scope significantly: it now works with non-GitHub repositories, supports VS Code and JetBrains as surfaces, and lets you queue messages while the agent is mid-turn. The --remote flag transforms your local agent into a service you can access from anywhere.

This is excellent engineering. Clean, secure (sessions are private to the authenticated user), and integrated directly into the existing GitHub ecosystem. Business and Enterprise users get admin controls. The session link lives alongside your repo in the Agents tab. It’s clearly a first-class feature, not an afterthought.

What I Built in April

My Telegram bridge extension solves the same fundamental problem with a different architecture:

  • Bidirectional messaging — every Telegram message becomes a prompt, every response forwards back
  • Photo support — send images from your phone for vision analysis
  • Voice notes — transcribed via Whisper and forwarded as text
  • Cron scheduling — agents run on schedules, report back to Telegram automatically
  • Custom tools — telegram_send_message, telegram_send_photo, telegram_get_status

The entire thing is one .mjs file in .github/extensions/. No external servers, no Docker, no cloud functions. It uses the Telegram Bot API over HTTP — the same protocol Telegram has provided since 2015.

Here’s the key architectural difference: GitHub’s remote sessions stream your existing CLI session to a viewer. My Telegram bridge creates a new interaction surface — the agent is always listening, even when no terminal is open. Combined with cron-scheduled agents, it becomes a persistent service. My daily briefing agent fires at 6:30 AM and sends me a compiled report in Telegram before I’m out of bed.

I wrote more about this always-on pattern in the article about open-sourcing my home assistant — 17 agents, 16 extensions, all orchestrated through Telegram.

The Core Insight Both Approaches Share

Strip away the implementation details and both GitHub’s --remote and my Telegram bridge express the same thesis:

The interface to AI agents shouldn’t be limited to the device running them.

This sounds obvious in hindsight. But look at the AI coding tool landscape even six months ago: every tool assumed you were sitting at your computer, staring at the terminal, actively supervising. The “agentic” revolution was still tethered to a physical desk.

The insight that unlocks everything is recognizing that agents don’t need real-time supervision — they need periodic steering. And steering can happen from anywhere. A quick message from your phone while you’re in line at the grocery store. A plan approval while waiting for your kid’s soccer practice to end. A “stop, wrong approach” while scrolling on the couch.

The reason mobile-first matters isn’t convenience. It’s parallelism. When your agent interaction model requires a terminal in front of you, you’re serializing your attention. One task at a time. But when your agent can work autonomously and you steer from your phone, suddenly you’re genuinely running parallel workflows. The agent handles the mechanical work; you handle judgment calls asynchronously.

Where the DIY Approach Goes Further

GitHub’s implementation is polished and production-ready out of the box. But an extension-based approach has capabilities that a platform-native solution can’t easily replicate:

Multi-agent orchestration. I’m not steering one session. I’m running 53 agents that communicate via cross-session mesh. An orchestrator agent dispatches work to specialized sub-agents — finance, content, scheduling, health — and they report back through Telegram. Try doing that with a single --remote session.

Proactive notifications. GitHub’s remote sessions are pull-based: you open the link to check status. My Telegram bridge is push-based: the agent messages me when something needs attention. “Your CI failed on PR #47.” “Your briefing is ready.” “The grocery order is confirmed.” No polling required.

Governance hooks. Because it’s an extension, I wire it into my hookflow system — approval gates, spending limits, safety protocols. The agent can’t merge a PR without my explicit Telegram reply of “approved.” That’s not just remote access — it’s remote governance.

Platform independence. Telegram works on iOS, Android, desktop, web, tablets, smartwatches. It works offline and syncs when you reconnect. It doesn’t require a GitHub account on the device. My wife can send my agent a message (“add diapers to the grocery list”) without knowing what GitHub is.

What This Convergence Means for the Industry

When GitHub, a platform serving 150M+ developers, ships a feature that independent builders already prototyped — that’s a signal. It means:

  1. Mobile-first agent interaction is table stakes. Every AI coding tool will ship this within 12 months. The desk-bound model is dead.

  2. The extension ecosystem is where innovation happens. My Telegram bridge existed months before the native feature because Copilot CLI extensions let you build outside the product roadmap. The extensibility model is the product.

  3. The real competition isn’t between tools — it’s between interaction paradigms. Chat interfaces, terminal sessions, IDE panels, mobile apps, Telegram bots, voice commands — the winners will be platforms that support all of them simultaneously.

  4. Agents are becoming services, not tools. A tool requires your presence. A service works for you whether you’re watching or not. GitHub’s --remote moves Copilot from tool toward service. Extensions like the Telegram bridge complete that transformation.

What’s Next

GitHub’s remote sessions will get better. I expect deeper mobile app integration, richer notification controls, and eventually multi-session management from the phone. The public preview to GA path was fast — barely 5 weeks — which tells me the team has conviction about this direction.

On my end, I’m pushing the Telegram bridge toward voice-first interaction. Voice notes already work via Whisper transcription, but I want real-time voice conversations with my agent — think phone calls, not text messages. I’m also exploring MCP-connected phones as a deeper integration layer where the agent doesn’t just receive messages from your phone — it controls phone capabilities directly.

The future isn’t “AI in the terminal.” The future is AI everywhere, steered from whatever device you’re holding. GitHub just proved that isn’t a fringe opinion — it’s the roadmap.

Resources

MÜSİAD, Tiran'da “Hizmetlerin Geleceği” forumu düzenledi

Arnavutluk’un başkenti Tiran’da MÜSİAD tarafından düzenlenen forumda, yapay zeka ve dijital dönüşümün hizmet sektörüne etkisi ele alındı. Türkiye ve Arnavutluk yetkilileri, teknolojinin insanı destekleyen bir araç olarak geliştirilmesi gerektiğini vurguladı.