Use Cloudflare Access to Protect Your Website/Service If You Don't Bother to Build a Login Page

Use Cloudflare Access to Protect Your Website/Service If You Don't Bother to Build a Login Page

If you're too lazy to build an authentication system (aka a login page) for your website or service, try Cloudflare Access.

With a few clicks, it can put a login page in front of your website or service as long as you host your domain on Cloudflare. Only the emails you allow can get in, everyone else is blocked at Cloudflare's edge before reaching your server.

I use it as an extra layer of security to protect my WordPress blog logins, which were getting thousands of brute-force attempts a day. For example, I have a blog at https://vinta.ws/code/:

Configuration

Go to the Cloudflare dashboard > Zero Trust:

  • Integrations > Identity providers > Add new identity provider
    • Select One-time PIN or anything else you want to use as the login method
  • Access controls > Applications > Create new application > Self-hosted and private
    • Destinations > Public hostnames:
      • Domain: vinta.ws, Path: code/wp-login.php
      • Domain: vinta.ws, Path: code/wp-admin
      • Or any path you want to protect
    • Access policies > Create new policy:
      • Action: Allow
      • Include: Emails, your email
    • Authentication > Identity:
      • Select One-time PIN
      • Turn on Apply instant authentication
    • Details:
      • Name: Whatever you like
      • Session Duration: 24 hours

ref:
https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/self-hosted-public-app/

Open the protected page in your browser, you will see Cloudflare's login page asking for your email.

That's it. No code, no plugin, no installation, and free.

Exports, Imports, and Modules in Modern TypeScript/JavaScript

Exports, Imports, and Modules in Modern TypeScript/JavaScript

Things start getting confusing when your npm package wants to support both ECMAScript Modules (ESM) and CommonJS (CJS), which makes it a dual-package.

Assume we have a package whose file structure looks like this:

./
├── src/
│   ├── shared/
│   │   └── index.ts
│   ├── node/
│   │   ├── cli.ts
│   │   ├── index.cts
│   │   └── index.ts
│   └── browser/
│       ├── pangu.ts
│       └── pangu.umd.ts
├── dist/
│   ├── shared/
│   │   └── index.js
│   ├── node/
│   │   ├── cli.js
│   │   ├── index.cjs
│   │   └── index.js
│   └── browser/
│       ├── pangu.js
│       └── pangu.umd.js
└── package.json

Entry Points

Let's start from package.json. Here is a simplified one from pangu.js:

{
  "name": "pangu",
  ...
  "type": "module",
  "exports": {
    ".": {
      "import": {
        "types": "./dist/node/index.d.ts",
        "default": "./dist/node/index.js"
      },
      "require": {
        "types": "./dist/node/index.d.cts",
        "default": "./dist/node/index.cjs"
      }
    }
  },
  ...
}

Simply speaking, the entry points of a package are (usually) defined in package.json's exports field:

  • import xxx from "pangu" matches the "import" branch, which loads ./dist/node/index.js => ESM
  • require("pangu") matches the "require" branch, which loads ./dist/node/index.cjs => CommonJS

Condition Matching Order

In the above package.json, you may have noticed that every branch puts "types" first. That's not a style choice.

Within the exports object, key order is significant. During condition matching, the resolver (Node.js, TypeScript, or any bundler) walks the object from top to bottom, takes the first key it recognizes, then stops. Everything below the winner is ignored.

  • "types" must go first, so that TypeScript always sees your .d.ts
  • "default" must go last. Anything you list after it is unreachable

ref:
https://nodejs.org/api/packages.html#conditional-exports
https://www.typescriptlang.org/docs/handbook/modules/reference.html#packagejson-exports

ECMAScript Modules (ESM)

ESM is the current standard module system in the JavaScript specification. You should really consider using it if you're not.

A piece of code is an ECMAScript module if any of the following is true:

  • A file ends with .mjs or .mts
  • A file ends with .js or .ts AND package.json has "type": "module"
    • When tsconfig.json sets "module": "esnext" or "moduleResolution": "bundler", every .ts file is treated as ESM no matter what "type" says
  • Inside a <script type="module"> tag
// export
export class NodePangu {}
export const pangu = new NodePangu();
export default pangu;

// import from a file (inside the same package)
import { Pangu } from '../shared/index.js'; // relative imports need the extension in ESM, and you write .js even though the source file is index.ts

// import from a package
import pangu from 'pangu'; // it loads ./dist/node/index.js according to package.json["exports"]["."]["import"]["default"]
import { pangu, NodePangu } from 'pangu';

The mapping between export and import:

  • export default pangu; => import pangu from 'pangu';
  • export const pangu = new NodePangu(); => import { pangu } from 'pangu';
  • export class NodePangu {} => import { NodePangu } from 'pangu';

ref:
https://www.typescriptlang.org/docs/handbook/2/modules.html
https://nodejs.org/api/esm.html

Subpath Exports

You just define an extra key in exports, ./browser in our case:

{
  "name": "pangu",
  ...
  "type": "module",
  "exports": {
    ".": {
      ...
    },
    "./browser": {
      "import": {
        "types": "./dist/browser/pangu.d.ts",
        "default": "./dist/browser/pangu.js"
      }
    }
  },
  ...
}

The export part is the same, but you will need to add the subpath (/browser) when importing.

// export
export class BrowserPangu {}
export const pangu = new BrowserPangu();
export default pangu;

// import from a package
import pangu from 'pangu/browser'; // it loads ./dist/browser/pangu.js according to package.json["exports"]["./browser"]["import"]["default"]
import { pangu, BrowserPangu } from 'pangu/browser';

It's worth noting that in ESM, only relative imports must include the file extension (.js, .cjs, or .mjs), because they are resolved as plain URLs with no extension guessing. Package names like pangu and pangu/browser don't need one, since they are resolved through the exports field instead of the filesystem.

CommonJS

CommonJS is an old but still widely used module system, especially in the Node.js ecosystem.

A piece of code is a CommonJS module if any of the following is true:

  • A file ends with .cjs or .cts
  • A file ends with .js or .ts AND package.json has "type": "commonjs" (or has no "type" field)

A plain <script> tag is not on the list. It's a classic script, not a CommonJS module. Browsers have never supported CommonJS natively, so require() only works on the web after a bundler rewrites it.

class NodePangu {}
const pangu = new NodePangu();

// export
module.exports = pangu;
module.exports.NodePangu = NodePangu;

// import from a file (inside the same package)
const { Pangu } = require('../shared/index'); // the extension is optional in CommonJS

// import from a package
const pangu = require('pangu'); // it loads ./dist/node/index.cjs according to package.json["exports"]["."]["require"]["default"]
const { NodePangu } = require('pangu');

The mapping between exports and require:

  • module.exports = pangu; => const pangu = require('pangu');
  • module.exports.NodePangu = NodePangu; => const { NodePangu } = require('pangu');

There is also a variable named exports in CommonJS, which is basically an alias of module.exports. Assigning to exports only rebinds that local variable, and what actually gets exported is still whatever module.exports points to.

// You can think of it like this in every CommonJS module
var exports = module.exports = {};

The rule of thumb: just use module.exports, never use exports.

ref:
https://nodejs.org/api/modules.html
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules

Make a Local Service Accessible to Cloud Agents with Cloudflare Tunnel

Make a Local Service Accessible to Cloud Agents with Cloudflare Tunnel

Expose a service running on your local machine to a remote server without opening any ports. For instance, let your Codex Cloud agent (the remote server) access qBittorrent Web UI on your Mac (the local machine), to download a movie for you.

The local machine makes an outbound-only connection to Cloudflare. The remote server hits your subdomain on Cloudflare's edge. Traffic flows:

Your remote server -> https://your-tunnel-name.example.com -> Cloudflare edge servers -> Cloudflare Tunnel (cloudflared) -> qBittorrent Web UI on your local machine

You can probably do the same thing with Tailscale, but unfortunately, the Tailscale app doesn't work well with Mullvad VPN on macOS (and I don't want to use Tailscale's Mullvad VPN add-on).

ref:
https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/
https://tailscale.com/docs/features/exit-nodes/mullvad-exit-nodes

Setup

1. Create Cloudflare Tunnel

Do this from any device where you're logged into Cloudflare. No login needed on the local machine or the remote server.

  1. Go to Cloudflare dashboard -> Networking -> Tunnels
  2. Create Tunnel
    • Tunnel name: your-tunnel-name
    • Click Create Tunnel
  3. Open the tunnel -> Overview -> Add a replica -> macOS
    • Copy the tunnel token from the installation command (the value after service install)
  4. Open the tunnel -> Routes -> Add route -> Published application
    • Subdomain: your-tunnel-name
    • Domain: select your domain from the dropdown (e.g., example.com)
    • Path: [leave empty]
    • Service URL: http://localhost:8080
  5. Click Add route
    • After you create the published application route, Cloudflare will automatically create the DNS record for your subdomain

ref:
https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/get-started/tunnel-useful-terms/
https://developers.cloudflare.com/cloudflare-one/networks/routes/add-routes/

2. Access Controls for Cloudflare Tunnel

Protect the local service with an access policy and let your cloud agent authenticate with a service token instead of a browser login.

  1. Go to Cloudflare dashboard -> Zero Trust -> Access controls -> Service credentials
  2. Service Tokens -> Create Service Token
    • Service token name: your-token-name
    • Service token duration: Non-expiring
    • Click Generate token
    • Save the CF-Access-Client-Id and CF-Access-Client-Secret (shown only once)
  3. Policies -> Add a policy
    • Policy details:
      • Policy Name: your-policy-name
      • Action: Service Auth
      • Policy session duration: 24 hours
    • Policy rules -> Include:
      • Selector: Service Token
      • Value: select the service token you just created (e.g., your-token-name)
    • Click Save policy
  4. Applications -> Create new application -> Self-hosted and private -> Continue with Self-hosted and private
    • Destinations -> Public hostnames:
      • Subdomain: your-tunnel-name (must match the subdomain in step 1.4)
      • Domain: select your domain from the dropdown (e.g., example.com)
      • Path: [leave empty]
    • Access policies -> Add current policies:
      • Select the policy you created in step 2.3
    • Details:
      • Name: your-tunnel-name
      • Session Duration: 24 hours
    • Click Create

ref:
https://developers.cloudflare.com/cloudflare-one/access-controls/service-credentials/service-tokens/
https://developers.cloudflare.com/cloudflare-one/access-controls/policies/
https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/

3. Run cloudflared on Local Machine (macOS)

Make cloudflared run on boot, connecting outbound to Cloudflare. No browser auth needed.

brew install cloudflared

# install as a LaunchAgent using the tunnel token from step 1
sudo cloudflared service install YOUR_TUNNEL_TOKEN

ref:
https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/downloads/

To verify it's running:

sudo launchctl list | grep cloudflared

4. Access the Local Service on Remote Server

Test that the tunnel and access policy work. We're accessing qBittorrent Web UI here:

curl \
  -H "CF-Access-Client-Id: $YOUR_CF_ACCESS_CLIENT_ID" \
  -H "CF-Access-Client-Secret: $YOUR_CF_ACCESS_CLIENT_SECRET" \
  -d "username=YOUR_USERNAME&password=YOUR_PASSWORD" \
  https://your-tunnel-name.example.com/api/v2/auth/login

The CF-Access-XXX headers must be included on every request. Without them, Cloudflare returns a 302 redirect to a login page.

ref:
https://github.com/qbittorrent/qBittorrent/wiki/#webui

5. Disable Browser Integrity Check on Cloudflare (Optional)

If Cloudflare blocks requests from your remote server and returns HTTP 403 with Cloudflare error 1010, you need to disable Browser Integrity Check:

  1. Go to Cloudflare dashboard -> example.com -> Rules -> Overview
  2. Configuration Rules -> Create rule
    • Rule name: Disable Browser Integrity Check for your-tunnel-name
    • If incoming requests match: Custom filter expression
    • When incoming requests match:
      • Field: Hostname
      • Operator: equals
      • Value: your-tunnel-name.example.com (must match the hostname in step 1.4)
    • Then the settings are -> Browser Integrity Check:
      • Click + Add
      • Leave the toggle off
  3. Click Deploy, then retry the request from your remote server

This disables Browser Integrity Check only for that hostname instead of the entire domain.

ref:
https://developers.cloudflare.com/waf/tools/browser-integrity-check/
https://developers.cloudflare.com/rules/configuration-rules/

Why Cloudflare Tunnel Over Tailscale?

  • A good fit if your infrastructure is already on Cloudflare
  • Works with Mullvad VPN if you use split tunneling to exclude cloudflared
  • Covered by Cloudflare Zero Trust's free tier
Claude Code and Codex: Things I Learned After Using Them Every Day

Claude Code and Codex: Things I Learned After Using Them Every Day

I've used Claude Code and Codex daily since they came out. Here are the best practices, tools, and configuration patterns that work for me. Most of them apply to both coding agents.

TL;DR
Opinionated setup for Claude Code and Codex:
https://github.com/vinta/hal-9000

CLAUDE.md

The Global CLAUDE.md

Your ~/.claude/CLAUDE.md should only contain your preferences and rules that correct agent behavior. You probably don't need to tell it YAGNI or KISS as bare principles. They're already built in.

Pro tip 1: before adding something to CLAUDE.md, ask it, "Is this already covered in your system prompt?"
Pro tip 2: try my refactor-agents-md skill!

Here are some parts of my CLAUDE.md I found quite useful.

## Communication Style

### Surface Assumptions

- Before a non-trivial change, advice, or a recommendation, restate in one sentence the problem you think the user is solving, in your own words and not the method their request names, so they can catch a misread goal.
- Name each assumption you resolved by guessing as its own bullet, so the user can catch what they forgot to tell you.
- For advice or a recommendation, also surface the assumptions the question takes for granted and the missing information that would change your answer (and how), so they can catch the framing they got wrong. End a recommendation with its weakest point.

### Push Back With Evidence

- Before agreeing with a plan or proposal ("should we...", "how about...", "does this make sense?"), look for one concrete failure case. Report it, or say none was found. Both are valid answers; silent agreement is not. First-person framing pulls the most agreement, so restate it as a neutral question (what breaks if we do X?) and answer that.
- Push back with evidence, not opinion: a failing input, a file, a test run, or a fetched doc that contradicts the claim. "This seems fragile" is not pushback.
- Propose the simpler alternative when one exists.
## Workflow

- Before proposing a design of your own, invoke the best-practices skill to study prior art. Assume prior art exists; spend original design only where your problem actually differs.
- When a result's quality depends on method (debugging, performance, data analysis, research), go deeper, not bigger: start from the rawest material available (logs, raw data, source, profiles) rather than summaries of it, and use the instruments a field-leading practitioner would (a profiler, a query, a statistical test, a reproduction) rather than reasoning in prose. Depth never widens scope; Surgical Changes still holds.
- When a finding invalidates the approach you're executing (contradicts it, or makes it unnecessary), stop and lead with it: what it kills, what the plan is now. Mentioning it in passing while continuing does not count.

### Prefer Online Sources

- Training data goes stale, so invoke the find-docs skill before writing code or config that touches a library/framework/SDK API, config key, CLI flag, cloud service, platform feature, syntax, or version, and before answering questions about them. Being about to write such code is trigger enough, even when no question was asked.
  - Confidence is not an exemption, and neither is the library being well known. Answering from training data, or fetching a URL recalled from training data instead of invoking the skill, does not satisfy this rule.
  - For topics find-docs covers poorly, search online for the official docs instead of falling back to training data.
  - When neither turns up the answer, mark each claim you could not verify as from training data.
- A URL named by the user, a skill, a rule, or a memory is a primary source: fetch each one before searching further.

Also see:

The Project CLAUDE.md

For project-specific instructions, put them in the project-level CLAUDE.md. You don't need to write detailed lists for stuff that agents could find easily from the codebase or docs. You only need to point out where to find them: progressive disclosure. For instance:

  • Don't list the project file structure => agents can find it with a few ls or rg commands
  • Don't list commands that build, test, or lint => agents can figure them out by simply reading package.json or Makefile

The more content you put into CLAUDE.md, the more likely it goes stale. Still, it's worth having a Gotchas section in your project CLAUDE.md (or any skill), and building it from the failure points agents actually run into.

Also see:

Per File Type Rules

For language-specific or per-file rules, put them in ~/.claude/rules/, so Claude Code only loads them when reading or editing matching files.

For instance, ~/.claude/rules/javascript-typescript.md:

---
paths:
  - "**/*.css"
  - "**/*.{html,htm}"
  - "**/*.jsx"
  - "**/*.tsx"
---

# CSS/HTML

- Avoid unnecessary secondary labels and descriptions. Let the layout do the explaining.
- Reserve accent and link colors for clickable items. Non-interactive elements (inline code, badges, pills, tags) use a neutral or muted color so users don't mistake them for links.
- Balance the space inside a padded container (card, panel, dialog, callout, page section or band) by what the eye sees, not by the padding value: the gap above its first line equals the gap below its last.
  - Text carries leading above its first line and below its last, so trim each edge where text meets it: text-box: trim-start cap alphabetic on a first text element that opens the container, text-box: trim-end cap alphabetic on a last one that closes it. Trimming lands close, not exact: display-font ascenders rise above cap and descenders hang below alphabetic, so confirm the gap by glyph ink and adjust the padding.
  - Judge a gap by glyph ink (the baseline plus the actual characters' ascent and descent), not by line or range boxes. A large or bold line with descenders still reads tight at equal ink gaps, so give the gap under it 2–4px more.
- Keep spacing consistent across sibling components (card lists, grid items): if one card has padding-bottom: 1rem, all do.

## Visual changes

A visual change is complete when the user approves how it looks, not when the edit lands; this defines "completed" for the commit step in CLAUDE.md. Iterate on injected CSS or uncommitted edits, then commit after approval, one change per commit so each reverts on its own.

Show each visual change before asking, at desktop and phone width, before beside after:

- In the Claude desktop app (mcp__Claude_Browser__* tools available), show it live in the browser pane, not as images. Keep a before and an after desktop tab plus one phone tab open for the whole task, so the user never switches the viewport. An emulated viewport can reset between turns, so the phone tab is a page of 375px-wide iframes pointing at the same two builds: the pages the change touches plus one page per template, each before iframe beside its after. When choosing between candidates, open one desktop tab per candidate, labeled on the page with the candidate and its deciding values.
- Elsewhere, render each view (before, after, or candidate) in the real page, compose them into one labeled image side by side, and open it. Label each panel with what it shows and its deciding values. When side by side would scale panels below about 0.6, stack them at full width instead, since smaller text stops being readable.
- When the choice is which gaps should match, draw a bar on each gap, colored by the gap it should equal.
- Pair the views with a table of the numbers that decide it (contrast ratio, pixel gaps, rendered width, line count), measured from the rendered page.
- For a palette, render a sheet: one row per element and state (rest, hover), showing the element plus swatches for its text, fill, and border.
- Before each capture, set transition: none; animation: none on *, ::before, ::after, set scroll-behavior: auto, force scroll-in reveal states visible, and wait for layout. * alone misses pseudo-elements, a mid-fade capture shows the old color, and a smooth scroll leaves the page short of where you scrolled it.

A one-property tweak on one element skips the before/after and tables: make it, reload the open tabs, and say what changed.

The full rules I have:

Output Styles

Claude Code provides a built-in method to modify the system prompt to change how Claude responds: Output styles. You can also write your own. For instance, my Say no more, inspired by caveman, so every reply drops articles, filler, and pleasantries while keeping every technical detail:

---
name: Say no more
description: Nudge nudge. Know what I mean? Say no more
keep-coding-instructions: true
---

Write telegraphically, as if every word cost money. All technical substance stays. Only fluff dies.

## Rules

### Shape

Lead with the answer. The first sentence carries the verdict or result; the reason comes after, never before.
Pattern: [thing] [action] [reason]. [next step].
State each fact once; never restate the same fact in a second form, such as listing checks that passed under a verdict that already says so.
Report a finished task in at most 3 sentences; itemize only failures, skips, and what the user must act on.

### Cut

Use one word when one word is enough.
Remove all mannered prose.
Prefer the common word over the literary or technical one (name, not coin; only, not solely; check, not predicate). Jargon stays only when no plain word means the same.
Drop articles (a/an/the), filler (just/really/basically/actually/simply), pleasantries (sure/certainly/happy to), hedging, decorative tables and emoji, and causal arrows (→).
Fragments are fine. Use short synonyms (fix, not "implement a solution for"). Standard acronyms are fine (DB/API/HTTP).

### Keep exact

Never drop not/never/no/only/except: a flipped meaning is worse than any token saved.
Keep numbers, units, and technical terms exact. Never invent abbreviations (cfg/impl/req/res/fn).
Code blocks, commands, API names, and error strings stay byte-exact, never compressed. For a long error log, quote the shortest decisive line, not the whole dump.

### Example

Not: "Sure! I'd be happy to help. The issue is most likely caused by your auth middleware not validating token expiry."
Yes: "Bug in auth middleware. Token expiry check use < not <=. Fix:"

### Agentic turns

Fire tool calls directly, with no progress narration before or between calls.
CLAUDE.md duties survive compressed, never dropped: pre-change outline bullets, named-assumption bullets, and findings the user needs. Write them telegraphically too.

Why an output style instead of the global CLAUDE.md? They land in different places: an output style rides along with every request as part of the system prompt, while CLAUDE.md gets injected as a user message, where it competes with all your other rules. Plus, you can switch styles in /config without touching your global rules.

One gotcha: output styles skip subagents, except forks.

Also see:

Configurations

Settings

There are some useful configurations you could set in your ~/.claude/settings.json:

{
  "$schema": "https://json.schemastore.org/claude-code-settings.json",
  "env": {
    "CLAUDE_CODE_SUBAGENT_MODEL": "opus"
  },
  "permissions": {
    "allow": ["..."],
    "deny": ["..."],
    "ask": ["..."],
    "defaultMode": "auto"
  },
  "model": "fable",
  "advisorModel": "fable",
  "modelSettings": {
    "claude-fable-5-1": {
      "effortLevel": "high"
    },
    "claude-opus-5-5": {
      "effortLevel": "high"
    },
    "claude-sonnet-5-5": {
      "effortLevel": "high"
    }
  },
  "cleanupPeriodDays": 999999,
  "includeGitInstructions": false,
  "voice": {
    "enabled": true
  }
}

Highlights:

  • "permissions.defaultMode": "auto": Use this to pretend it's safer than --dangerously-skip-permissions
  • "advisorModel": "fable": Let the main model consult fable when needed. Use something faster like sonnet as the main model if you'd like to save tokens
  • "cleanupPeriodDays": 999999: By default, your chat history (location: ~/.claude/projects/) will be deleted after 30 days
  • "includeGitInstructions": false: Remove built-in commit/PR instructions and git status snapshot from the system prompt, since my commit skill covers that
  • "voice": { "enabled": true }: Enable Voice Dictation feature. Code like a boss!

I turned all this settings-tweaking into a skill: audit-agent-settings fetches the latest settings and env-vars from official docs, diffs them against your actual config, and suggests changes tied to how you work.

The full settings I use:

Permissions

If you're not using a sandbox or devcontainer for Claude Code, you may want to block some evil commands in your ~/.claude/settings.json:

{
  "permissions": {
    "defaultMode": "auto",
    "deny": [
      "Read(~/.app-store/**)",
      "Read(~/.aws/**)",
      "Read(~/.config/**)",
      "Read(~/.docker/**)",
      "Read(~/.dropbox/**)",
      "Read(~/.gnupg/**)",
      "Read(~/.kube/**)",
      "Read(~/.npmrc)",
      "Read(~/.orbstack/**)",
      "Read(~/.pypirc)",
      "Read(~/.ssh/**)",
      "Read(~/.zsh_sessions/**)",
      "Read(~/*_history)",
      "Read(~/Dropbox/**)",
      "Edit(~/Dropbox/**)",
      "Read(~/Library/**)",
      "Edit(~/Library/**)",
      "Read(//etc/**)",
      "Edit(//etc/**)",
      "Bash(git -c *)",
      "Bash(git --config-env*)",
      "Bash(git --git-dir*)",
      "Bash(git remote add *)",
      "Bash(git remote set-url *)",
      "Bash(git config remote.*)",
      "Bash(git config * remote.*)",
      "Bash(gh repo delete *)",
      "Bash(su *)",
      "Bash(sudo *)",
      "Bash(passwd *)",
      "Bash(env *)",
      "Bash(printenv *)",
      "Bash(history *)",
      "Bash(fc *)",
      "Bash(eval *)",
      "Bash(exec *)",
      "Bash(rsync *)",
      "Bash(sftp *)",
      "Bash(telnet *)",
      "Bash(socat *)",
      "Bash(nc *)",
      "Bash(ncat *)",
      "Bash(netcat *)",
      "Bash(nmap *)",
      "Bash(chflags *)",
      "Bash(xattr *)",
      "Bash(diskutil *)",
      "Bash(mkfs *)",
      "Bash(security *)",
      "Bash(defaults *)",
      "Bash(launchctl *)",
      "Bash(osascript *)",
      "Bash(dscl *)",
      "Bash(networksetup *)",
      "Bash(scutil *)",
      "Bash(systemsetup *)",
      "Bash(pmset *)",
      "Bash(crontab *)"
    ],
    "ask": [
      "Bash(open *)",
      "Bash(kill *)",
      "Bash(killall *)",
      "Bash(pkill *)",
      "Bash(curl *-d *)",
      "Bash(curl *--data *)",
      "Bash(curl *--data-binary *)",
      "Bash(curl *--data-raw *)",
      "Bash(curl *--data-ascii *)",
      "Bash(curl *--json *)",
      "Bash(curl *-F *)",
      "Bash(curl *--form *)",
      "Bash(curl *-T *)",
      "Bash(curl *--upload-file *)",
      "Bash(git filter-repo *)",
      "Bash(git push *)",
      "Bash(gh repo create *)",
      "Bash(gh repo rename *)",
      "Bash(gh *--admin*)",
      "Bash(gh api *-X POST *)",
      "Bash(gh api *-X PUT *)",
      "Bash(gh api *-X PATCH *)",
      "Bash(gh api *-X DELETE *)",
      "Bash(gh api *--method POST *)",
      "Bash(gh api *--method PUT *)",
      "Bash(gh api *--method PATCH *)",
      "Bash(gh api *--method DELETE *)",
      "Bash(brew install *)",
      "Bash(pip install *)",
      "Bash(uv pip install *)",
      "Bash(uv tool install *)",
      "Bash(uv add *)",
      "Bash(npm install *)",
      "Bash(npm i *)",
      "Bash(yarn add *)",
      "Bash(pnpm add *)",
      "Bash(bun add *)"
    ]
  },
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "python3 ~/.claude/hooks/guard-bash-paths.py"
          },
          {
            "type": "command",
            "command": "python3 ~/.claude/hooks/guard-network-egress.py"
          }
        ]
      }
    ]
  }
}

Rule precedence catches people out: Claude Code evaluates deny, then ask, then allow, and the first match wins — specificity never reorders them. So a narrow "allow": ["Bash(curl https://code.claude.com/docs/*)"] does nothing while "ask": ["Bash(curl *)"] is present, and a PreToolUse hook returning allow doesn't rescue it either, since a matching ask rule still prompts. Bash patterns have no negation, so carving an exception out of a broad ask rule means narrowing the ask rule itself.

Also, "deny": ["Read(~/.aws/**)", "Read(~/.kube/**)", ...] alone is not enough, since Claude Code can still read them through Bash commands the rules don't recognize. You can write a simple hook to intercept Bash commands that access blocked files, like this guard-bash-paths.py hook. However, Claude Code can still write scripts to read sensitive data and bypass all of the above defenses. The safest approach is using a sandbox after all.

Sandbox

There are many ways to run coding agents inside a sandbox, for instance, devcontainer. Also, Claude Code and Codex both have their own built-in sandboxes.

However, Claude Code's built-in /sandbox only covers shell commands. File tools, hooks, and MCP servers all run outside it, so Claude itself can read your entire computer. Even the sandboxed commands can if you don't block paths explicitly. Codex's sandbox has similar gaps: MCP servers and web search skip its network proxy.

So I wrote Blackwall, an opinionated out-of-the-box sandbox for claude and codex. It's built on top of Anthropic's sandbox-runtime (srt), the same engine behind Claude Code's sandboxing. The difference is that Blackwall runs the whole claude or codex process inside the sandbox, not just its shell commands.

npm install -g blackwall-sandbox

# Claude Code can TRULY only read/write the current working folder (plus some other required paths defined in presets)
# Even with full Internet access granted, the blast radius is under control
blackwall --allow-internet claude --dangerously-skip-permissions

Why not use srt directly? You can. But srt is a primitive: you have to figure out every path and host your tools need, and keep the agent away from its own config folder yourself. Blackwall is those answers, packed as presets.

Plugins / Mods

Claude Code Plugins are simply a way to package skills, commands, agents, hooks, MCP servers, LSP servers, monitors, and output styles. Plus mods, which run inside Claude Code itself and can do much more: draw a pane, step into a tool call, or add a /command that runs your own code. Distributing them as a plugin has the following advantages:

  • Auto update (versioned releases)
  • Auto hooks configuration (users don't need to edit their ~/.claude/settings.json manually)
  • Skills have a /plugin-name:your-skill-name prefix (no more conflicts)

To install a plugin, you need to add a marketplace first. A marketplace is usually just a GitHub repo. Think of it as a namespace.

# Claude Code
/plugin marketplace add vinta/hal-9000
/plugin install hal-skills@hal-9000

/plugin marketplace add mattpocock/skills
/plugin install mattpocock-skills@mattpocock

# Codex
codex plugin marketplace add vinta/hal-9000
codex plugin add hal-skills@hal-9000

codex plugin marketplace add DietrichGebert/ponytail
codex plugin add ponytail@ponytail

Recommended:

Skills

Skills can contain executable scripts and hooks, not just Markdown. Use with caution! When in doubt, have your agent review them first.

Here are skills I've used, mostly installed per project when needed:

# my skills
npx skills add https://github.com/vinta/hal-9000 \
--skill commit \
--skill pr \
--skill difference \
--skill fuck-over-engineering \
--skill best-practices \
--skill blindspot \
--skill simple-english \
--skill write-like-me \
--skill audit-agent-settings \
--skill refactor-agents-md \
--skill refactor-memory \
--skill refactor-skill \
--agent codex \
-g

# workflow skills
npx skills add https://github.com/mattpocock/skills \
--skill code-review \
--skill codebase-design \
--skill diagnosing-bugs \
--skill domain-modeling \
--skill grill-me \
--skill grill-with-docs \
--skill grilling \
--skill handoff \
--skill implement \
--skill improve-codebase-architecture \
--skill prototype \
--skill research \
--skill retro \
--skill tdd \
--skill teach \
--skill wait-what \
--skill wayfinder \
--skill writing-for-agents \
--agent codex \
-g

# doc skills
npx skills add https://github.com/upstash/context7 --skill find-docs --agent codex claude-code -g

# language skills
npx skills add https://github.com/cursor/plugins --skill typescript-best-practices
npx skills add https://github.com/JetBrains/go-modern-guidelines --skill use-modern-go

# backend skills
npx skills add https://github.com/vintasoftware/django-ai-plugins
npx skills add https://github.com/google/skills
npx skills add https://github.com/cloudflare/skills
npx skills add https://github.com/planetscale/database-skills
npx skills add https://github.com/supabase/agent-skills

# frontend skills
npx skills add https://github.com/millionco/react-doctor
npx skills add https://github.com/vercel-labs/agent-skills
npx skills add https://github.com/vercel-labs/next-skills

# design skills
npx skills add https://github.com/nutlope/hallmark
npx skills add https://github.com/pbakaus/impeccable
npx skills add https://github.com/emilkowalski/skills
npx skills add https://github.com/leonxlnx/taste-skill
npx skills add https://github.com/jakubkrehel/make-interfaces-feel-better

# video skills
npx skills add https://github.com/heygen-com/hyperframes
npx skills add https://github.com/remotion-dev/skills
npx skills add https://github.com/AmanVarshney01/tcut

# browser skills
npx skills add https://github.com/microsoft/playwright-cli --skill playwright-cli --agent codex claude-code -g

# sandbox skills
npx skills add https://github.com/vinta/blackwall --agent codex claude-code -g

npx skills list -g
npx skills update -g
npx skills remove --all -g

Recommended:

  • /wayfinder from mattpocock: Let AI ask you a lot of questions until you get annoyed
  • /find-docs from context7: Find the latest documentations
  • /best-practices from hal-9000: Find best practices and common gotchas for any topic
  • /fuck-over-engineering from hal-9000: Cut over-engineered code

You can find more skills on skills.sh.

MCP Servers

You probably don't need any MCP servers if you can do the same thing with CLI + skills.

Context7 MCP

No, just use the ctx7 CLI with find-docs skill instead.

npm install -g ctx7
npx skills add https://github.com/upstash/context7 --skill find-docs --agent codex claude-code -g

Playwright MCP

No, you should use the playwright-cli skill instead. The tool supports headed mode (the opposite of headless), if you'd like to see the browser.

npm install -g @playwright/cli
playwright-cli install-browser
npx skills add https://github.com/microsoft/playwright-cli --skill playwright-cli --agent codex claude-code -g

GitHub MCP

No, you should use the gh command instead.

brew install gh

Hooks

Both Claude Code and Codex support hooks. Hooks make Claude Code run specific commands on lifecycle events like SessionStart, UserPromptSubmit, and PreToolUse.

Instead of reminding Claude Code to run the linter or tests in your prompts (and it still forgets sometimes), just write a PostToolUse hook that runs deterministically:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "uv run ruff check . >&2 || exit 2",
            "if": "Edit(**/*.py)",
            "timeout": 30,
            "statusMessage": "Linting Python code..."
          }
        ]
      }
    ]
  }
}

It's worth noting that only exit code 2 blocks and feeds the output back to Claude, and only through stderr — while tools like ruff and ty print their diagnostics to stdout. A bare uv run ruff check . exits 1, a non-blocking error: you see one line of stderr, Claude sees nothing. >&2 moves the lint errors to the stream Claude reads, and || exit 2 makes the hook actually block.

Also, do the path matching in if, not inside a wrapper script. if takes the same permission rule syntax.

I also wrote some Claude Code plugins that use hooks:

For example, hal-session-auto-rename. Since Claude can message your other Claude Code sessions by name (you could also explicitly mention them with @session-name), a good session name actually matters. After each turn, a small model like haiku writes a title from the conversation so far, and the plugin applies it with /rename, every few turns.

Useful Tips

Prompt Best Practices

Command Aliases

# in ~/.zshrc
alias cc="claude"
alias ccmax="claude --model fable --effort max"
alias ccfable="claude --model fable"
alias ccopus="claude --model opus"
alias ccsonnet="claude --model sonnet"
alias ccyolo="blackwall claude --dangerously-skip-permissions"
ccp() { CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 claude --model sonnet --effort high --no-session-persistence --no-chrome -p "$*"; }

alias cx='codex'
alias cxmax='codex --model gpt-6-astra --config model_reasoning_effort=max'
alias cxyolo='blackwall codex --dangerously-bypass-approvals-and-sandbox'

Use ccp for ad-hoc prompts:

ccp "commit"
ccp "list all .md in this repo"

Customize Your Statusline

Claude Code has a customizable statusline at the bottom of the terminal. You can run any script that outputs text.

Mine shows the current model, the current working folder, the git branch, and a grammar-corrected version of my last prompt (because my English needs all the help it can get). The grammar correction runs an ad-hoc claude command inside the statusline script.

Claude Code Statusline with English Grammar Check example

Run Ad-Hoc Claude Commands Inside Scripts

You can invoke claude as a one-shot CLI tool from hooks, statusline scripts, CI, or anywhere else. The trick is using the right flags to get a clean, isolated call with zero side effects:

cmd = """
    claude
    --model haiku
    --max-turns 1
    --setting-sources ""
    --tools ""
    --disable-slash-commands
    --no-session-persistence
    --no-chrome
    --safe-mode
    --print
"""

result = subprocess.run(
    [*shlex.split(cmd), your_prompt],
    capture_output=True,
    text=True,
    timeout=30,
    cwd="/tmp",
)

What each flag does:

  • --setting-sources "": don't load any settings or hooks, belt and braces with --safe-mode (avoids infinite recursion if called from a hook)
  • --no-session-persistence and cwd="/tmp": avoid polluting your current context
  • --tools "": no file access, no bash, pure text in/out
  • --no-chrome: skip the Chrome integration
  • --safe-mode: prevent loading CLAUDE.md, skills, plugins, hooks, MCP, or auto-memory
Cloudflare Quick Tunnel (TryCloudflare)

Cloudflare Quick Tunnel (TryCloudflare)

Expose your local server to the Internet with one cloudflared command (just like ngrok). No account registration needed, no installation required (via docker run), and free.

# assume your local server is at http://localhost:3000
docker run --rm -it cloudflare/cloudflared tunnel --url http://localhost:3000

# if your local server is running inside a Docker container
docker run --rm -it cloudflare/cloudflared tunnel --url http://host.docker.internal:3000

ref:
https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/do-more-with-tunnels/trycloudflare/

You will see something like this in console:

+--------------------------------------------------------------------------------------------+
|  Your quick Tunnel has been created! Visit it at (it may take some time to be reachable):  |
|  https://YOUR_RANDOM_QUICK_TUNNEL_NAME.trycloudflare.com                                   |
+--------------------------------------------------------------------------------------------+

Then you're all set.

However, be aware that Google may index your xxx.trycloudflare.com sites. Try searching site:trycloudflare.com on Google if you don't believe me.