Rider 2026.2 Early Access Program Begins With Performance Improvements

The Early Access Program (EAP) for Rider 2026.2 is now open, and the first preview build for the upcoming major release is already out. 

There are several ways for you to get your hands on the first preview build:

  • Download and install it from our website.
  • Get it via the Toolbox App.
  • Install this snap package from the SnapCraft store if you’re using a compatible Linux distribution.
Download Rider 2026.2 EAP 1

A reminder of what the EAP is all about

The Early Access Program is a long-standing tradition that gives our users early access to the new features we’re preparing. By participating, you get a first look at what’s coming and a chance to help shape the final release through your feedback.

EAP builds are free to use, though they may be less stable than the final release versions. You can learn more about the EAP and why you might want to participate here.

And now on to Rider 2026.2 EAP 1 release highlights.

Major Roslyn performance improvements with faster branch switching

Rider 2026.2 EAP 1 introduces a significant round of performance improvements for Roslyn integration, with a focus on one of the most painful scenarios in large solutions: switching branches.

Branch switching is one of those everyday actions that should feel uneventful. You change branches, Rider updates the solution model, Roslyn catches up, and you keep working. But in large solutions, especially those with many projects or target frameworks, this process could become noticeably slow. In some cases, it could also cause freezes or Roslyn crashes.

Rider 2026.2 EAP 1 addresses this with a set of targeted improvements to how Rider communicates project model changes to Roslyn. We’ve reduced the number of requests, added batching, cut down the amount of transferred data, and fixed a hang caused by passing non-existent files to Roslyn.

The result is a much smoother experience when switching branches, especially in large or complex solutions. In typical large-project scenarios, branch switching is now 2–3x faster

In some of the worst cases we tested, the improvement is much more dramatic. One BenchmarkDotNet scenario (~25 projects included) improved from 8 minutes to 5 seconds, making branch switching in that case nearly 100x faster.

This work also fixes a number of Roslyn-related issues around project references, .editorconfig handling, available analyzers, and target framework changes.

Game dev goodness

Unity 

For Unity developers, we’ve significantly reworked how Rider handles asmdef references. This should improve how Rider understands Unity projects that use assembly definition files and make project model updates more reliable.

Godot 

Rider 2026.2 EAP 1 brings a set of fixes and quality improvements for GDScript support, addressing several issues that could make the editing experience less smooth than expected.

Spellchecking is now available in GDScript files, helping you catch typos directly in the editor. 

Azure Functions support is moving into Rider

We’re migrating Azure Functions features for local development from the separate Azure Toolkit plugin into JetBrains Rider itself.

This means you’ll be able to develop Azure Functions locally without installing any additional plugins. Most of the existing functionality has already been moved, including project and trigger creation, running, debugging, Azurite integration, and more. A few smaller features are still pending and will be added in upcoming EAP builds.

We’ve also added the ability to create an Azure Functions trigger from the project creation dialog. In addition, Azure Functions projects can now be debugged inside a Docker container. Previously, this Docker debugging workflow was available only for regular .NET projects.

Aspire improvements

Rider 2026.2 EAP 1 also includes several updates for Aspire.

We now support file-based AppHosts for Aspire projects. Dev certificate validation for Aspire apps has also been improved.

There are also improvements to how AppHost.cs is displayed in the editor. Rider now shows the status of each resource, such as whether it’s running or stopped, and lets you execute resource commands directly from the gutter.


For the full list of changes included in this build, please see our release notes.

We encourage you to download the EAP build, give these new features a try, and share your feedback. The Early Access Program is a collaborative effort, and your input plays a vital role in making Rider the best it can be.

Download Rider 2026.2 EAP 1

Thank you for being part of our EAP community, and we look forward to hearing what you think!

[Tutorial] Building a Shielded Token dApp on Midnight: From Compact Contract to React UI

📁 Full source code: midnight-apps/shielded-token

Target audience: Developers

This tutorial walks you through building a complete shielded token DApp on the Midnight network. You will deploy a Compact smart contract, implement operations such as minting, transferring, and burning tokens, generate zero-knowledge proofs, and build a React frontend that lets users interact with shielded tokens in the browser.

Shielded tokens differ from unshielded tokens in that all balances and amounts remain hidden from on-chain explorers. Only the wallet owner can decrypt their balances locally. The smart contract proves correctness via zero-knowledge proofs without revealing any sensitive values. Public state variables such as totalSupply and totalBurned track aggregate metrics, while individual coin values, recipients, and the transaction graph remain private.

Prerequisites

  • Node.js installed (v20+)
  • A Midnight Wallet (1AM or Lace)
  • Some Preprod faucet NIGHT tokens
  • A package.json with the needed packages
    • @midnight-ntwrk/compact-runtime
    • @midnight-ntwrk/dapp-connector-api
    • @midnight-ntwrk/ledger-v8
    • @midnight-ntwrk/midnight-js-contracts
    • @midnight-ntwrk/midnight-js-dapp-connector-proof-provider
    • @midnight-ntwrk/midnight-js-fetch-zk-config-provider
    • @midnight-ntwrk/midnight-js-indexer-public-data-provider
    • @midnight-ntwrk/midnight-js-level-private-state-provider
    • @midnight-ntwrk/midnight-js-network-id
    • @midnight-ntwrk/midnight-js-types
    • @midnight-ntwrk/wallet-sdk-address-format
    • react, react-dom, react-router-dom, zustand, semver

1. Building and compiling the smart contract

The smart contract for shielded tokens resides in contracts/Token.compact. It manages public counters such as totalSupply and totalBurned, and uses the Zswap shielded token primitives to create, transfer, and destroy private coins.

Public ledger state

Create these two essential public counters to track the token lifecycle:


// --- Public ledger state ---

export ledger totalSupply: Uint<64>;
export ledger totalBurned: Uint<128>;

These two are public: they do not contain any sensitive or private information. They only track totalSupply and totalBurned; the ownership of the shielded tokens remains private.

Witnesses for private data

The Compact smart contract for shielded tokens requires a source of randomness for coin nonces. Each shielded coin needs to have a unique nonce so its commitment is distinct:


// --- Witnesses for private/off-chain data ---

witness localNonce(): Bytes<32>;

For every mint, a fresh random 32-byte nonce is generated. It lives in the TypeScript layer and is bound into the zero-knowledge proof generation.

Minting a shielded token

The first circuit is createShieldedToken. It mints a new shielded token with a unique nonce and sends it to a recipient:


// --- Minting to self ---

export circuit createShieldedToken(
    amount: Uint<64>,
    recipient: Either<ZswapCoinPublicKey, ContractAddress>
): ShieldedCoinInfo {
    const domain = pad(32, "shielded:token");
    const nonce = localNonce();
    const coin = mintShieldedToken(
        disclose(domain),
        disclose(amount),
        disclose(nonce),
        disclose(recipient)
    );
    totalSupply = (totalSupply + disclose(amount)) as Uint<64>;
    return coin;
}

mintShieldedToken is a ledger primitive. It creates a new shielded token commitment. The domain separates this token from others on the network, and the nonce ensures its uniqueness.

Note: disclose() is required because the ledger needs to see the recipient on-chain in order to route the output correctly. Only the recipient can decrypt the actual amount.

The atomic mint-and-send pattern

mintAndSend is the most important circuit in this smart contract. It atomically mints a coin and forwards it to a recipient in one transaction without any Merkle qualification needed:


// --- Minting and sending ---

export circuit mintAndSend(
    amount: Uint<64>,
    recipient: Either<ZswapCoinPublicKey, ContractAddress>
): ShieldedSendResult {
    const domain = pad(32, "shielded:token");
    const nonce = localNonce();

    // Mint to contract first
    const coin = mintShieldedToken(
        disclose(domain),
        disclose(amount),
        disclose(nonce),
        right<ZswapCoinPublicKey, ContractAddress>(kernel.self())
    );

    // Immediately forward — no Merkle qualification needed
    const result = sendImmediateShielded(
        disclose(coin),
        disclose(recipient),
        disclose(amount) as Uint<128>
    );

    totalSupply = (totalSupply + disclose(amount)) as Uint<64>;
    return result;
}

sendImmediateShielded spends a token that was created in the same transaction. The kernel pairs the mint and spend internally using mt_index: 0, meaning no on-chain Merkle path lookup is needed.

The ShieldedSendResult contains two fields:

  • sent: the coin that was sent to the recipient
  • change: a Maybe<ShieldedCoinInfo> containing any remainder

The Merkle tree constraint

To understand why tokens need to be committed to the on-chain Merkle tree: freshly minted shielded tokens are not immediately spendable in an independent transaction. Thus the exported circuit transferShielded requires QualifiedShieldedCoinInfo (which includes mt_index), while the mintAndSend circuit bypasses this by using sendImmediateShielded.

export circuit transferShielded(
    coin: QualifiedShieldedCoinInfo,
    recipient: Either<ZswapCoinPublicKey, ContractAddress>,
    amount: Uint<128>
): ShieldedSendResult {
    const result = sendShielded(disclose(coin), disclose(recipient), disclose(amount));
    return result;
}

sendShielded requires a Merkle inclusion proof from coin.mt_index to the current Zswap root. The prover must have this path, and the verifier checks it against the on-chain root. If the wallet’s local Zswap state is even slightly out of sync with the verifier’s expected root, then the proof fails.

This is a trade-off to be considered carefully depending on your use case(s):

Primitive Requires mt_index Use case
sendImmediateShielded No Same-tx mint/send or deposit/burn
sendShielded Yes Spending previously committed coins

Burning shielded tokens

The depositAndBurn circuit burns the received coin in the same transaction:

export circuit depositAndBurn(
    coin: ShieldedCoinInfo,
    amount: Uint<128>
): ShieldedSendResult {
    receiveShielded(disclose(coin));
    const burnAddr = shieldedBurnAddress();
    const result = sendImmediateShielded(
        disclose(coin),
        burnAddr,
        disclose(amount)
    );
    totalBurned = (totalBurned + disclose(amount)) as Uint<128>;
    return result;
}

receiveShielded declares that the smart contract receives the coin. The wallet’s balancer adds a matching input automatically. shieldedBurnAddress() is a ledger constant on the Midnight network; coins sent there are permanently removed from the circulating supply.

Important Caveat: sendImmediateShielded sends change to kernel.self() (the smart contract). Thus a partial burn leaves a contract-owned shielded output that is not tracked elsewhere. The UI enforces full burn by default to avoid this.

Additional circuits

nextNonce is used to derive a deterministic nonce sequence:

export circuit nextNonce(index: Uint<128>, currentNonce: Bytes<32>): Bytes<32> {
    return evolveNonce(disclose(index), disclose(currentNonce));
}

evolveNonce is used to derive the next nonce from a counter index and current nonce; it’s useful for applications requiring deterministic nonce sequences.

View the full contract in Token.compact.

Compiling the compact smart contract

Install the Compact compiler:

curl --proto '=https' --tlsv1.2 -LsSf 
  https://github.com/midnightntwrk/compact/releases/latest/download/compact-installer.sh | sh

Then compile:

compact compile contracts/Token.compact src/contracts

This will generate files and folders such as keys and zkir, all of which are essential for deploying and interacting with the smart contract later.

Note: You can skip this step if you cloned the repo, as compiled artifacts are already included. However, if you recompile, you will not be able to use the deployed smart contract because the old verification keys will no longer match.

2. React UI implementation

Using the smart contract-generated artifacts in src/contracts from the frontend involves a few steps:

Wallet provider setup

Midnight wallets inject a global window.midnight object before page load.

Start with the constants:

// src/hooks/wallet.constants.ts
export const COMPATIBLE_CONNECTOR_API_VERSION = '4.x';
export const NETWORK_ID = 'preprod';

Note: COMPATIBLE_CONNECTOR_API_VERSION is '4.x', not '^4.0.0'. The '4.x' semver range accepts any 4.x.y version the wallet reports.

The detection function enumerates window.midnight, validates each entry, and filters by version.

// src/hooks/useWallet.ts
export function getCompatibleWallets(): InitialAPI[] {
  if (!window.midnight) return [];

  return Object.values(window.midnight).filter(
    (wallet): wallet is InitialAPI =>
      !!wallet &&
      typeof wallet === 'object' &&
      'apiVersion' in wallet &&
      semver.satisfies(wallet.apiVersion, COMPATIBLE_CONNECTOR_API_VERSION)
  );
}

When wallet.connect(networkId) is called, it triggers the wallet extension connection flow.

// src/hooks/useWallet.ts
connect: async (networkId = NETWORK_ID) => {
  const { wallet } = get();
  if (!wallet) {
    set({ error: 'No wallet selected' });
    return;
  }

  set({ isConnecting: true, error: null });

  try {
    const connectedApi = await wallet.connect(networkId);
    const status = await connectedApi.getConnectionStatus();

    if (status.status !== 'connected') {
      throw new Error(`Wallet status: ${status.status}`);
    }

    const config = await connectedApi.getConfiguration();
    const shielded = await connectedApi.getShieldedAddresses();
    const unshielded = await connectedApi.getUnshieldedAddress();
    const dustAddr = await connectedApi.getDustAddress();

    set({
      connectedApi,
      isConnected: true,
      config,
      addresses: {
        shieldedAddress: shielded.shieldedAddress,
        shieldedCoinPublicKey: shielded.shieldedCoinPublicKey,
        shieldedEncryptionPublicKey: shielded.shieldedEncryptionPublicKey,
        unshieldedAddress: unshielded.unshieldedAddress,
        dustAddress: dustAddr.dustAddress,
      },
      balances: {
        shielded: {},
        unshielded: {},
        dust: { balance: 0n, cap: 0n },
      },
    });

    localStorage.setItem('midnight_last_wallet', wallet.rdns);
  } catch (err) {
    set({
      error: err instanceof Error ? err.message : 'Connection failed',
      isConnected: false,
      connectedApi: null,
    });
  } finally {
    set({ isConnecting: false });
  }
},

Or if you want, you can use a starter I built, dapp-connect.

First, start by cloning the repository.

git clone https://github.com/0xfdbu/midnight-apps.git

Run the starter and install dependencies.

cd midnight-apps/dapp-connect
npm install
npm run dev

Building the providers and the TypeScript API

Before continuing, you need a helper function to build the providers.

// src/hooks/wallet/services/providers.ts

import type { ConnectedAPI } from '@midnight-ntwrk/dapp-connector-api';
import type { MidnightProviders } from '@midnight-ntwrk/midnight-js-types';
import { INDEXER_HTTP, INDEXER_WS, CONTRACT_PATH, PRIVATE_STATE_PASSWORD } from '../wallet.constants';
import { indexerPublicDataProvider } from '@midnight-ntwrk/midnight-js-indexer-public-data-provider';
import { FetchZkConfigProvider } from '@midnight-ntwrk/midnight-js-fetch-zk-config-provider';
import type { ZKConfigProvider } from '@midnight-ntwrk/midnight-js-types';
import { dappConnectorProofProvider } from '@midnight-ntwrk/midnight-js-dapp-connector-proof-provider';
import { levelPrivateStateProvider } from '@midnight-ntwrk/midnight-js-level-private-state-provider';
import { toHex, fromHex } from '@midnight-ntwrk/midnight-js-utils';
import { Transaction, CostModel } from '@midnight-ntwrk/ledger-v8';

Provider builder function:

export async function buildProviders(
  connectedApi: ConnectedAPI,
  coinPublicKey: string,
  encryptionPublicKey: string,
  contractAddress?: string,
  existingPrivateStateProvider?: any
): Promise<MidnightProviders> {
  const fetchProvider = new FetchZkConfigProvider(
    `${window.location.origin}${CONTRACT_PATH}`,
    fetch.bind(window)
  );
  const zkConfigProvider = new ArtifactValidatingProvider(fetchProvider);

  const privateStateProvider = existingPrivateStateProvider || levelPrivateStateProvider({
    accountId: coinPublicKey,
    privateStoragePasswordProvider: () => PRIVATE_STATE_PASSWORD,
  });

  if (contractAddress) {
    privateStateProvider.setContractAddress(contractAddress);
  }

  return {
    privateStateProvider,
    publicDataProvider: indexerPublicDataProvider(INDEXER_HTTP, INDEXER_WS),
    zkConfigProvider,
    proofProvider: await dappConnectorProofProvider(connectedApi, zkConfigProvider, CostModel.initialCostModel()),
    walletProvider: {
      getCoinPublicKey: () => coinPublicKey,
      getEncryptionPublicKey: () => encryptionPublicKey,
      async balanceTx(tx: any, _ttl?: Date): Promise<any> {
        const serializedTx = toHex(tx.serialize());
        const received = await connectedApi.balanceUnsealedTransaction(serializedTx);
        return Transaction.deserialize('signature', 'proof', 'binding', fromHex(received.tx));
      },
    },
    midnightProvider: {
      async submitTx(tx: any): Promise<string> {
        await connectedApi.submitTransaction(toHex(tx.serialize()));
        const txIdentifiers = (tx as any).identifiers();
        return txIdentifiers?.[0] ?? '';
      },
    },
  };
}

Now proceed to create the hook for the TypeScript API. These are some of the essential imports for the API

// src/hooks/wallet/services/api.ts

import type { ConnectedAPI } from '@midnight-ntwrk/dapp-connector-api';
import { indexerPublicDataProvider } from '@midnight-ntwrk/midnight-js-indexer-public-data-provider';
import { buildProviders } from './providers';
import { getContract, createInitialPrivateState } from './contract';
import { INDEXER_HTTP, INDEXER_WS, CONTRACT_PATH, PRIVATE_STATE_ID, PRIVATE_STATE_PASSWORD } from '../wallet.constants';
import { levelPrivateStateProvider } from '@midnight-ntwrk/midnight-js-level-private-state-provider';
import { CompiledContract } from '@midnight-ntwrk/compact-js';

Deploying the smart contract

deployTokenContract builds a CompiledContract instance, binds the localNonce witness, attaches the compiled ZK artifacts, and then calls deployContract with the providers:

// src/hooks/wallet/services/api.ts

export async function deployTokenContract(
  connectedApi: ConnectedAPI,
  coinPublicKey: string,
  encryptionPublicKey: string
): Promise<string> {
  const { deployContract } = await import('@midnight-ntwrk/midnight-js-contracts');
  const privateStateProvider = await ensurePrivateState(coinPublicKey, 'tmp-deploy');
  const providers = await buildProviders(connectedApi, coinPublicKey, encryptionPublicKey, undefined, privateStateProvider);

  const contractModule = await import(`${CONTRACT_PATH}/contract/index.js`);
  const cc: any = CompiledContract.make('shielded-token', contractModule.Contract);
  const withWitnesses = (CompiledContract as any).withWitnesses({
    localNonce: ({ privateState }: any): [any, Uint8Array] => {
      const nonce = crypto.getRandomValues(new Uint8Array(32));
      return [privateState, nonce];
    },
  });
  const withAssets = (CompiledContract as any).withCompiledFileAssets(CONTRACT_PATH);
  const compiledContract = withWitnesses(withAssets(cc));

  const deployed = await deployContract(providers as any, {
    compiledContract,
    privateStateId: PRIVATE_STATE_ID,
    initialPrivateState: createInitialPrivateState(),
    args: [],
  } as any);

  const address = deployed.deployTxData.public.contractAddress;
  localStorage.setItem('shielded_token_contract', address);
  return address;
}

Wire deployTokenContract into the frontend

// src/pages/Deploy.tsx
// Other imports
import { useWalletStore } from '../hooks/useWallet';
import { deployTokenContract } from '../hooks/wallet/services/api';

  const handleDeploy = async () => {
    if (!connectedApi || !addresses?.shieldedCoinPublicKey || !addresses?.shieldedEncryptionPublicKey) {
      setError('Wallet not fully connected');
      return;
    }
    setStatus('pending');
    setError(null);

    try {
      const addr = await deployTokenContract(
        connectedApi,
        addresses.shieldedCoinPublicKey,
        addresses.shieldedEncryptionPublicKey
      );
      setContractAddress(addr);
      setStatus('success');
    } catch (err) {
      console.error('[Deploy] Error:', err);
      setError(err instanceof Error ? err.message : 'Deployment failed');
      setStatus('error');
    }
  };

The smart contract address is then saved to localStorage.

Note: The same API pattern used in deployTokenContract will be used for calling the compiled circuits. View full API api.ts

Minting tokens

The Mint page has two modes: Mint to Self and Mint & Send.

Mint to Self calls createShieldedToken and sends the minted coin into the user’s shielded coin public key:

const selfRecipient = {
  is_left: true,
  left: { bytes: parseKeyBytes(addresses.shieldedCoinPublicKey) },
  right: { bytes: ZERO_BYTES32 },
};

const result = await callCreateShieldedToken(
  connectedApi,
  addresses.shieldedCoinPublicKey,
  addresses.shieldedEncryptionPublicKey,
  value,
  selfRecipient
);

When a mint is successful, Nonce, Color, and Value are stored in localStorage so they can be referenced later during the burn phase.

Mint & Send calls mintAndSend and sends the freshly minted coins to the address the user entered:

const recipientBytes = parseShieldedAddress(recipient);
const recipientEither = {
  is_left: true,
  left: { bytes: recipientBytes },
  right: { bytes: ZERO_BYTES32 },
};

const result = await callMintAndSend(
  connectedApi,
  addresses.shieldedCoinPublicKey,
  addresses.shieldedEncryptionPublicKey,
  value,
  recipientEither
);

A small utility function, parseShieldedAddress, extracts the 32 bytes from the user-typed shielded address

/**
 * Parse a Bech32m shielded address (e.g. `m1q...`) and extract the 32-byte
 * shielded coin public key that the smart contract expects as a recipient.
 */
export function parseShieldedAddress(address: string): Uint8Array {
  try {
    const parsed = MidnightBech32m.parse(address);
    const shieldedAddr = ShieldedAddress.codec.decode(getNetworkId(), parsed);
    return new Uint8Array(shieldedAddr.coinPublicKey.data);
  } catch {
    throw new Error('Invalid shielded address. Paste a Bech32m address starting with the network prefix.');
  }
}

When a mint is successful, Nonce, Color, and Value are stored in localStorage so they can be referenced later during the burn phase. This means users won’t need to enter the values manually when they are already stored in localStorage.

Note: The createShieldedToken circuit returns ShieldedCoinInfo, while the mintAndSend circuit returns a ShieldedSendResult containing sent and change. For mintAndSend with exact amounts, change is typically None.

Coin storage

Shielded coins are different from unshielded ones: they are private, and the wallet does not expose an API to enumerate them with their nonces, so the DApp stores mint results in localStorage.

export interface StoredCoin {
  id: string;
  nonce: string;
  color: string;
  value: string;
  source: 'mint' | 'mintAndSend' | 'change';
  txId: string;
  createdAt: string;
}

Mint page writes using saveStoredCoins and burn page reads using getStoredCoins. Sending tokens from wallet does not require reading or writing.

Sending tokens

The send page uses the wallet’s native makeTransfer for shielded transfers. The wallet handles everything, including proving; however, you still need to call submitTransaction to broadcast it:

const desiredOutput = {
  kind: 'shielded' as const,
  type: selectedToken,
  value,
  recipient: recipientClean,
};

const result = await connectedApi.makeTransfer([desiredOutput]);
if (result.tx) {
  await connectedApi.submitTransaction(result.tx);
}

makeTransfer is the most convenient way of sending shielded tokens using the DApp Connector API.

Burning tokens

The Burn page uses the depositAndBurn circuit to destroy stored coins

const coin = {
  nonce: hexToUint8Array(selectedCoin.nonce),
  color: hexToUint8Array(selectedCoin.color),
  value: BigInt(selectedCoin.value),
};

const result = await callDepositAndBurn(
  connectedApi,
  addresses.shieldedCoinPublicKey,
  addresses.shieldedEncryptionPublicKey,
  coin,
  BigInt(amount)
);

After burning, the coin is removed from localStorage.

const updatedCoins = getStoredCoins().filter((c) => c.id !== selectedCoin.id);
saveStoredCoins(updatedCoins);

Caveat: sendImmediateShielded sends change to kernel.self() (smart contract). Therefore, a partial burn leaves a contract-owned shielded output that is not tracked anywhere, which is why the UI enforces a full burn by default to avoid this.

Home and balance display

The main dashboard displays 3 types of data:

Shielded balance(s) – it displays the combined balance of tokens, shieldedBalanceTotal, that enumerates across all balances. It also calls connectedApi.getShieldedBalances() internally and refreshes every 15 seconds:

const { balances, loadWalletState } = useWalletStore();

useEffect(() => {
  if (!isConnected) return;
  loadWalletState();
  const id = setInterval(() => loadWalletState(), 15_000);
  return () => clearInterval(id);
}, [isConnected, loadWalletState]);

const shieldedBalanceTotal = (() => {
  if (!balances?.shielded) return null;
  const entries = Object.entries(balances.shielded);
  if (entries.length === 0) return 0n;
  return entries.reduce((sum, [, v]) => sum + (v ?? 0n), 0n);
})();

Contract states like totalSupply and totalBurned are fetched via the getContractState helper, which uses ledger() to deserialize the raw bytes into readable data.

const [stats, setStats] = useState<{ totalSupply: bigint; totalBurned: bigint } | null>(null);

useEffect(() => {
  if (contractAddress) {
    getContractState(contractAddress).then(setStats);
  }
}, [contractAddress]);

3. The mint-and-send atomic pattern

The mintAndSend circuit pattern solves a critical problem in shielded token design.

The main issue is that a freshly minted shielded coin is not immediately spendable via sendShielded when it has not yet been committed to the Merkle tree. If you mint a coin in transaction X, you cannot spend it in transaction X+1 without waiting for it to be included in the Merkle tree and obtaining its mt_index.

sendImmediateShielded is different, it bypasses the Merkle qualification by using mt_index: 0.

The circuit pattern:

  1. mintShieldedToken(..., kernel.self()) — mint shielded coins to the kernel (smart contract)
  2. sendImmediateShielded(coin, recipient, amount) — forward to the recipient

Either both steps succeed, or the entire transaction fails. The recipient receives a fully qualified shielded coin that is spendable in future transactions with sendShielded once it is committed to the Merkle tree.

depositAndBurn circuit pattern:

  1. receiveShielded(coin) — deposits user coins into the transaction
  2. sendImmediateShielded(coin, burnAddr, amount) — burn it immediately in the same transaction

This atomic pattern makes it possible to burn a shielded coin through the smart contract without using sendShielded with mt_index, which requires the commitment of the coin to the Merkle tree.

4. Key architectural decisions

Decision Choice Rationale
Proving strategy dappConnectorProofProvider (wallet-backed) Built-in ledger circuits like output are not generated by the Compact compiler; the wallet has them
Send path Wallet makeTransfer for transfers, smart contract depositAndBurn for burns makeTransfer handles change correctly; smart contract burns update totalBurned
Coin storage localStorage via coinStore.ts The DApp Connector API does not expose individual coin nonces; storing mint results enables smart contract burns
Burn default Full burn Partial burns via depositAndBurn lock change in the smart contract
Network Preprod Testnet with faucet support

Conclusion

You have now built a complete shielded token DApp that demonstrates the ability to mint privacy-preserving tokens with mintShieldedToken, atomically forward freshly minted coins with sendImmediateShielded, burn tokens with receiveShielded + sendImmediateShielded, and finally build a React frontend with deploy, mint, send, burn, and balance display.

It is important to distinguish between sendImmediateShielded (bypasses Merkle path before spending) and sendShielded (requires mt_index). Understanding this correctly determines whether the coins you minted are immediately spendable or locked.

Next steps

  • Check the full repository source code on GitHub
  • Read the Midnight Compact language docs
  • Experiment with transferShielded by storing mt_index for committed coins
  • Add admin authentication to restrict minting privileges

Troubleshooting

Symptom Cause Fix
Shielded balance shows 0 after mint Wallet hasn’t synced the mint block yet Wait 15s (auto-refresh) or open wallet extension to trigger sync
Burn page empty dropdown Burn only shows DApp-minted coins, not wallet-received coins Use Send page (makeTransfer to burn address) for wallet balance burns
Wallet disconnects during proving ZK proof generation timed out in wallet popup Reconnect wallet, ensure extension is active and unlocked
"Invalid shielded address" on Mint & Send Recipient field expects Bech32m, not raw hex Use parseShieldedAddress() to decode the wallet’s shielded address
Invalid Transaction: Custom error: 138 on burn 1AM wallet dust sponsoring interferes with contract call balancing Turn off dust sponsoring in 1AM wallet settings
"No compatible wallet found" Extension API version outside 4.x Update Lace or 1AM to latest version

My SonarQube scans were crawling; turns out Docker on WSL 2 only had 1 CPU and 1 GB of RAM

If you’re running SonarQube (or anything CPU- and memory-hungry) inside Docker Desktop on Windows and the scans feel like they’re running through molasses, your .wslconfig is probably the first place to look. That was my story this week, and the fix was satisfyingly small.

Here’s the whole journey, end to end, including the gotchas.

Symptom: scans stall, fans spin, nothing finishes

I was running a SonarQube scan on a mid-sized codebase inside a Docker container on Windows. The scan would chug along for a few minutes, then either crawl or stall outright. Nothing in the SonarQube logs screamed “out of memory” but the host felt fine, so it wasn’t a Windows-side resource problem.

Diagnosis: ask Docker what it actually has

Docker Desktop on Windows runs everything inside a WSL 2 VM called docker-desktop. Whatever resources you’ve given that VM is the ceiling for every container you run. You can ask it directly:

wsl -d docker-desktop -- nproc
wsl -d docker-desktop -- free -h

The output I got:

1
              total        used        free      shared  buff/cache   available
Mem:           907M       ...
Swap:          4.0G        ...

One CPU. Less than 1 GB of RAM. No wonder. SonarQube’s analyzer plus the Java heap alone wants more than that. Anything that hit a swap-heavy phase was going to thrash.

Fix: .wslconfig

WSL 2 reads global VM settings from a single file at %UserProfile%.wslconfig. It doesn’t exist by default you create it. Mine ended up looking like this:

[wsl2]
memory=8GB
processors=4
swap=8GB

Three lines. That’s the whole fix.

A few notes on syntax that tripped me up briefly:

  • The section header must be [wsl2] — lowercase, exact.
  • memory and swap are “size” values. You must include the unit (GB or MB). Per Microsoft’s spec: “Entries with the size value default to B (bytes), and the unit is omissible.” Which is a polite way of saying that swap=4 means 4 bytes, not 4 GB. Don’t do what I almost did.
  • No spaces around the =.
  • processors is an integer.

Apply it

Save the file to C:Users<you>.wslconfig, then in PowerShell:

wsl --shutdown

Quit Docker Desktop from the tray icon and start it again. That’s it.

The 8-second rule. Microsoft’s docs are explicit about this: even after wsl --shutdown, give it a moment for the VM to fully stop before relaunching. You can verify with:

wsl --list --running

If nothing’s listed, you’re safe to start back up.

Verify

After Docker came back up:

PS> wsl -d docker-desktop -- nproc
4

PS> wsl -d docker-desktop -- free -h
              total        used        free      shared  buff/cache   available
Mem:           7.8G      485.8M        6.6G        3.1M      697.7M        7.1G
Swap:          8.0G           0        8.0G

4 CPUs, 7.8 GB RAM (the 8 GB cap, minus a sliver of overhead), 8 GB of swap. Exactly what the config asked for.

The SonarQube scan that had been crawling finished in a sensible amount of time on the next run.

Optional extras worth knowing about

If you want to go further, the [wsl2] section supports a bunch of other documented keys. The two I’d actually consider for a SonarQube-style workload:

[wsl2]
memory=8GB
processors=4
swap=8GB
vmIdleTimeout=60000

[experimental]
autoMemoryReclaim=gradual
sparseVhd=true
  • vmIdleTimeout shuts the VM down after 60 s of idle, so it’s not hoarding 8 GB while you’re not using Docker.
  • autoMemoryReclaim=gradual returns idle memory back to Windows slowly instead of dropping caches abruptly nicer for scans that have memory spikes.
  • sparseVhd=true makes new WSL VHDs sparse, so disk usage actually shrinks when you free space inside the VM.

Gotchas I want to save you from

A few things I learned the hard way or almost did:

Don’t allocate everything. Setting memory to your entire physical RAM, or processors to your full core count, will starve Windows itself. Aim for roughly 75% of physical RAM and leave a couple of cores for the host.

pageReporting isn’t a real key. I saw it referenced in older blog posts. It’s not in the current Microsoft spec – WSL silently ignores unknown keys, so the file looks valid but the setting does nothing. Stick to the documented list.

Path values need escaped backslashes. If you set swapFile or kernel, write it as C:\Temp\swap.vhdx, not C:Tempswap.vhdx.

Docker Desktop’s docker-desktop VM is what you care about, not a user distro. When validating, run wsl -d docker-desktop -- free -h, not wsl -- free -h. The latter checks whatever default distro you have installed (Ubuntu or similar), which is a separate VM.

TL;DR

If Docker on Windows feels sluggish, check what WSL 2 is actually giving it:

wsl -d docker-desktop -- nproc
wsl -d docker-desktop -- free -h

If those numbers are tiny, drop a .wslconfig at %UserProfile%.wslconfig:

[wsl2]
memory=8GB
processors=4
swap=8GB

wsl --shutdown, restart Docker Desktop, verify, and get on with your day.

Reference: Microsoft’s advanced settings configuration in WSL.

Your MCP server eats 55,000 tokens before your agent says a word — I measured the real cost

The invisible bill

I was debugging why my Claude Code sessions felt sluggish after connecting a few MCP servers. Token usage was through the roof — but I hadn’t even asked the agent to do anything yet. I rewrote my prompts three times before I thought to check where the tokens were actually going.

Turns out, the moment you connect an MCP server, every tool definition gets loaded into the context window. Names, descriptions, parameter schemas, enum values — all of it, on every single conversation turn. Not just when you call a tool. Every turn.

Think of it like walking into a library to read one book, but the librarian insists you read the entire catalog first. Every time you walk in.

The measurement: 4 servers, 500x cost difference

I measured the tool-definition token overhead for four MCP servers, from minimal to massive:

MCP Server Tools Est. tokens Monthly cost (10 calls)
PostgreSQL 1 ~35 ~$0.0005
Google Maps 7 ~704 ~$0.009
GitHub 26 ~4,242 ~$0.06
GitHub (full) 93 ~55,000 ~$0.74

PostgreSQL to full GitHub: a 1,500x difference. Same protocol, same “MCP server” label, radically different cost profiles.

And this is just the definition overhead. The actual tool calls consume additional tokens on top.

Where the tokens go

A single MCP tool definition looks harmless:

{
  "name": "gmail_create_draft",
  "description": "Creates a draft email...",
  "inputSchema": {
    "type": "object",
    "properties": {
      "to": { "type": "string", "description": "..." },
      "subject": { "type": "string", "description": "..." },
      "body": { "type": "string", "description": "..." }
    }
  }
}

That single tool? 820 tokens. More than the entire PostgreSQL MCP server with its one tool.

Now multiply. A business API like a full accounting platform might expose 270+ tools across invoicing, HR, payroll, time tracking, and sales management. At ~65 tokens per tool average, that’s 17,500 tokens consumed before your first question.

Connect three services like that simultaneously, and you’re burning 143,000 out of 200,000 tokens on schema definitions alone. 71% of your context window is gone. Your agent is trying to think inside a closet.

At scale, the math gets uncomfortable: 1,000 requests/day with heavy MCP overhead = roughly $170/day = $5,100/month — just for loading tool schemas.

The quality cliff

Token cost isn’t even the worst part. Claude’s output quality visibly degrades after 50+ tool definitions are loaded. The model starts chasing tangents, referencing tools instead of answering your actual question.

More tools in context doesn’t mean more capability. Past a threshold, it means worse capability. I confirmed this firsthand — five servers connected, and my agent started recommending create_github_issue as the fix for a database timeout. Very confident. Very wrong.

Three strategies to cut 95%

Strategy 1: Expose only what you need

If you’re using an accounting platform’s 270 tools but only need 10 for your tax filing workflow:

{
  "mcpServers": {
    "accounting": {
      "allowedTools": [
        "create_transaction",
        "list_transactions",
        "get_trial_balance",
        "list_account_items",
        "list_partners"
      ]
    }
  }
}

10 tools instead of 270: ~650 tokens instead of ~17,500. 96% reduction.

Strategy 2: Write tighter descriptions

API docs make terrible tool descriptions. They’re written for humans who read documentation; LLMs need the compressed version.

// Before: ~80 tokens
{
  "description": "Uses the accounting API to create a new
    transaction (journal entry) for the specified company ID.
    You can specify amount, date, account item, partner name,
    memo, and more. Tax category is auto-determined."
}

// After: ~20 tokens
{
  "description": "Create transaction. Args: amount, date, account_item, partner"
}

75% fewer tokens, same functionality. The model doesn’t need a paragraph to understand what create_transaction does.

Strategy 3: Connect only when needed

Don’t keep all MCP servers connected during every conversation. Connect the accounting server when you’re doing accounting work. Disconnect it when you’re writing code. This alone zeroes out overhead for unrelated tasks.

MCP Tool Search: the protocol-level fix

In January 2026, a protocol-level solution arrived: MCP Tool Search. When tool definitions exceed 10% of your context window, the client automatically defers loading them. Instead of dumping every schema into context, the model discovers and loads tools on-demand via search.

Early reports show a 95% reduction in startup token cost. The schema bloat problem is being solved at the infrastructure level, not just through workarounds.

But Tool Search isn’t universally deployed yet. Until it is, the three strategies above are your defense.

What to check right now

1. Count your tools. Run tools/list against each connected MCP server and count the total. If you’re above 30 tools across all servers, you’re likely paying a meaningful overhead tax.

2. Audit descriptions. Look at the JSON schemas your servers return. Are the descriptions essay-length? Trim them. Every token in a description is paid on every conversation turn.

3. Use allowedTools. Most MCP clients support filtering which tools are exposed. Use it. There’s no reason to load 270 tools when you need 10.

4. Measure before/after. Token usage is visible in most LLM clients. Check your per-turn consumption before and after connecting each MCP server. The numbers will tell you exactly which servers are expensive.

The irony of MCP: the protocol designed to extend AI capabilities can end up crippling them — if you load too many tools and leave no room for actual thinking.

This article is based on Chapter 3 of MCP Security in Practice: What OWASP Won’t Tell You About AI Tool Integrations. The book covers the full token cost analysis across services, OWASP MCP Top 10 security risks, file upload limitations, and production hardening patterns.

How Intelligent Agents Work — From Perception to Decision and Action

AI is not just models.

It is a system that perceives, decides, and acts.

If you only think in terms of algorithms, you miss the bigger structure.

The real question is:

How does an AI system turn input into action?

Core Idea

An intelligent agent is the simplest way to understand AI as a system.

It takes input from the environment.

Processes that information.

Then selects an action.

That loop defines AI behavior.

The Key Structure

The basic agent loop looks like this:

Environment → Perception → State → Decision → Action → Environment

Or more compact:

Agent = Perception + Decision + Action

This is why the agent concept matters.

It connects data, reasoning, and behavior into one structure.

Implementation View

At a high level, an agent behaves like this:

observe environment

update internal state

evaluate possible actions

choose the best action

execute action

repeat

This loop appears everywhere.

Game AI.

Robotics.

Autonomous systems.

Recommendation systems.

Even large language models follow a version of this pattern.

Concrete Example

Imagine a simple robot.

It receives sensor input.

It detects obstacles.

It chooses a direction.

It moves.

That is already an intelligent agent.

Now scale that idea:

A recommendation system observes user behavior.

Updates internal preferences.

Chooses the next item to show.

That is also an agent.

Different domain.

Same structure.

Reactive vs Intelligent Agent

Not all agents are equal.

This comparison matters.

Reactive agent:

  • responds directly to input
  • no memory or internal model
  • simple and fast
  • limited flexibility

Intelligent agent:

  • maintains internal state
  • evaluates future outcomes
  • can optimize decisions
  • adapts to complex environments

So the difference is not just complexity.

It is the presence of internal reasoning.

Why Cognition Matters

As problems become more complex, simple reaction is not enough.

The agent needs internal representation.

Memory.

Inference.

That is where cognition comes in.

Cognitive systems treat thinking as information processing.

Input is transformed into internal structure.

That structure supports reasoning.

So the flow becomes:

Perception → Representation → Reasoning → Action

Without this layer, AI is limited to simple responses.

With it, AI can plan and infer.

Action vs Understanding

This is where things get interesting.

Does acting correctly mean understanding?

A system can follow rules and produce correct outputs.

But does it truly understand meaning?

This question is not just philosophical.

It affects how we interpret AI systems.

Rule-following can look like intelligence.

But it may not imply true understanding.

That distinction matters when designing or evaluating AI.

Decision vs Free Will

If an agent chooses actions, is that the same as free will?

In humans, experiments suggest decisions may begin before conscious awareness.

In AI, decisions are the result of computation.

So the deeper question becomes:

Is decision-making just a process?

Or is there something more?

Even if you do not answer it fully, this perspective helps you see AI systems differently.

They are not just tools.

They are structured decision systems.

From Agents to Modern AI Systems

The agent view scales.

Search algorithms:

  • choose next state

Knowledge-based systems:

  • use rules and inference

Neural networks:

  • learn representations

Modern AI combines these ideas.

Perception.

Representation.

Decision.

Learning.

The agent is the unifying abstraction.

Why This Matters

If you only learn models, you miss system design.

If you understand agents, you understand AI structure.

That matters in practice.

Because real systems are not just one model.

They are pipelines.

Loops.

Decision processes.

The agent view helps you design them.

Recommended Learning Order

If this feels broad, follow this order:

  1. Agent vs Intelligent Agent
  2. Intelligent Agent
  3. Cognitive Agents
  4. Cognitivism
  5. Chinese Room Argument
  6. Free Will and Decision Systems

This order works because you first understand action.

Then internal reasoning.

Then the limits of understanding.

Takeaway

AI is best understood as an agent.

Not just a model.

Not just an algorithm.

A system that:

  • perceives
  • represents
  • decides
  • acts

The shortest version is:

Agent = perception + decision + action

If you remember one idea, remember this:

AI systems are decision loops, not isolated models.

Discussion

When designing AI systems, do you think more in terms of models, or in terms of agents that interact with environments?

Originally published at zeromathai.com.
Original article: https://zeromathai.com/en/intelligent-agent-and-cognition-hub-en/

GitHub Resources
AI diagrams, study notes, and visual guides:
https://github.com/zeromathai/zeromathai-ai

How Knowledge-Based AI Works — From Rules to Inference

Before AI learned from massive datasets, many systems worked with explicit knowledge.

Facts.

Rules.

Inference.

That is the core of Knowledge-Based AI.

Core Idea

Knowledge-Based AI stores knowledge in a structured form.

Then it uses rules to derive new conclusions.

The system does not “learn” from data in the modern deep learning sense.

It reasons over what it already knows.

That makes the structure very different from machine learning.

The Key Structure

A simple Knowledge-Based AI system looks like this:

Knowledge Base → Rules → Inference Engine → Conclusion

Or more compact:

Knowledge-Based AI = Facts + Rules + Inference

The knowledge base stores information.

The rule system defines how conclusions can be derived.

The inference engine applies those rules.

Implementation View

At a high level, a rule-based system works like this:

store known facts

store IF-THEN rules

compare facts with rule conditions

apply matching rules

generate new facts or conclusions

repeat until no useful rule applies

This is why Knowledge-Based AI is easy to inspect.

You can often trace exactly which rule produced which conclusion.

That transparency is one of its biggest strengths.

Concrete Example

Imagine a simple medical expert system.

It may store facts like:

  • patient has fever
  • patient has cough
  • patient has fatigue

And rules like:

IF fever AND cough THEN possible infection

IF possible infection AND fatigue THEN recommend further test

The system does not train on millions of examples.

It applies explicit rules.

That makes the reasoning path easier to explain.

Rule-Based AI vs Machine Learning

This comparison is important.

Rule-Based AI:

  • uses explicit facts and rules
  • depends on human-designed knowledge
  • is easier to explain
  • struggles when rules become too many or too brittle

Machine Learning:

  • learns patterns from data
  • improves through training
  • handles noisy and complex data better
  • can be harder to interpret

So the difference is not just old AI vs modern AI.

It is symbolic reasoning vs data-driven learning.

Both solve problems in different ways.

Forward Chaining vs Backward Chaining

Even with the same rules, inference can move in different directions.

Forward chaining starts from known facts.

It applies rules until it reaches conclusions.

Backward chaining starts from a goal.

It works backward to check whether the needed conditions are true.

Forward chaining:

  • data-driven
  • useful when you want to discover what follows from known facts
  • starts with available evidence

Backward chaining:

  • goal-driven
  • useful when you want to prove or verify a target conclusion
  • starts with the question

The difference is simple:

Forward chaining asks:

“What can I conclude from what I know?”

Backward chaining asks:

“What must be true for this goal to hold?”

Why Inference Engines Matter

The inference engine is the part that makes the system active.

A knowledge base alone only stores information.

Rules alone only define possible logic.

The inference engine applies the rules to produce conclusions.

That is why it is the execution layer of Knowledge-Based AI.

Without inference, the system is just a database.

With inference, it becomes a reasoning system.

Why Expert Systems Were Important

Expert systems are one of the clearest applications of Knowledge-Based AI.

They encode domain knowledge from human experts.

Then they use rules to make recommendations or decisions.

Examples include:

  • medical diagnosis support
  • troubleshooting systems
  • configuration systems
  • rule-based decision support

Their biggest strength is explainability.

Their biggest weakness is maintenance.

As the domain grows, the rule base can become difficult to manage.

Logical Extensions

Knowledge-Based AI also connects to formal reasoning.

Logic programming, such as PROLOG, represents knowledge as logical relations.

Theorem proving uses formal logic to verify statements.

Commonsense reasoning tries to represent everyday assumptions that humans usually take for granted.

These extensions show the same basic idea:

Represent knowledge explicitly.

Then reason over it.

Recommended Learning Order

If Knowledge-Based AI feels broad, learn it in this order:

  1. Knowledge Base
  2. Rule-Based System
  3. Inference Engine
  4. Forward Chaining
  5. Backward Chaining
  6. Expert System
  7. Logic Programming
  8. Theorem Proving
  9. Commonsense Reasoning

This order works because you first understand storage.

Then rules.

Then inference.

Then practical and logical extensions.

Takeaway

Knowledge-Based AI is built on explicit knowledge and reasoning.

The shortest version is:

Knowledge-Based AI = facts + rules + inference

It is not mainly about learning from data.

It is about using stored knowledge to reach conclusions.

If you remember one idea, remember this:

A knowledge-based system becomes intelligent when stored rules can generate new conclusions from known facts.

Discussion

When building AI systems, do you prefer transparent rule-based reasoning, or flexible data-driven learning?

Originally published at zeromathai.com.
Original article: https://zeromathai.com/en/knowledge-based-ai-hub-en/

GitHub Resources
AI diagrams, study notes, and visual guides:
https://github.com/zeromathai/zeromathai-ai

TaskDev – a task runner for AI coding agents (MCP)

One place for your dev tasks. One place for your logs. And your AI agent sees them too.

Like most developers working on web apps, I usually have a few long-running processes open during the day:

  • the API server
  • the frontend dev server
  • a build watcher

Usually one terminal each. That works, but it is not the handiest setup – you end up jumping between tabs to check what is running and where the logs are.

TaskDev puts them in one place – and makes them visible to your AI agent over MCP.

TaskDev sidebar showing a project node with two tasks

Why I built TaskDev

Agents can read output, but they can’t manage processes.

AI coding agents – Codex, Claude Code, Windsurf Cascade, Cursor – write code well and can read terminal output. What they lack is a stable interface for starting, stopping, and tracking long-running processes. So they spawn duplicates, lose track of what is running, fight stuck ports, and retry until the developer takes over.

The Model Context Protocol (MCP) makes a unified solution possible: one task list that both the developer and the agent can drive.

That is TaskDev:

  • a sidebar for the developer
  • an MCP server for the agent
  • one source of truth – same tasks, same processes, same logs
  • agent commands are sandboxed (see Trust and safety below)

The agent problem, in detail

Long-running tasks like a web service are the worst case:

  • the agent forgets a task is already running and starts it again – and again
  • the previous process still holds the port, so the new one fails
  • it sometimes takes several attempts to stop a task, burning tokens for no reason
  • some agents spawn tasks in hidden terminals or redirect the console output, and the developer doesn’t see what is going on
  • the agent waits forever on a command that never returns

As a result, failed attempts, wasted tokens, and a developer forced to intervene.

The agent itself is not the issue. It just doesn’t have a reliable control interface to manage tasks.

TaskDev is a small, lightweight process supervisor that provides exactly that interface – start, stop, restart, status, logs.

What it is

A small extension for VS Code-based editors (VS Code, Cursor, Windsurf).

  • plain JSON config
  • local processes
  • local logs
  • no telemetry

Tasks are defined in taskdev.json at the root of the workspace.

Install TaskDev

Repository: github.com/tolbxela/taskdev – MIT license.

Install TaskDev from the Extensions panel – search for TaskDev:

  • VS Code → Visual Studio Marketplace
  • Cursor and Windsurf → Open VSX Registry

Then drop a taskdev.json in your workspace and run TaskDev: Install MCP config to wire up the agent side.

Configuration

Example for an ASP.NET Core + Vue.js project:

{
  "project": "My App",
  "tasks": [
    {
      "name": "api",
      "command": "dotnet run --project src/Api",
      "detail": "Starts the backend API",
      "icon": "server-process"
    },
    {
      "name": "ui",
      "type": "npm",
      "command": "npm run dev",
      "cwd": "ui",
      "detail": "Starts the Vite dev server",
      "icon": {
        "id": "globe",
        "color": "terminal.ansiBlue"
      }
    }
  ]
}

Each task needs a name and a command. Everything else is optional:

  • cwd – working directory for the command
  • env – extra environment variables
  • detail – short description shown in the sidebar
  • icon – a codicon id, or { id, color }
  • type – a free-form label like npm or dotnet

Add as many tasks as you want. Two shapes fit naturally:

  • long-running – dev server, build watcher, worker, tunnel, test watcher
  • repetitive – test run, lint, type-check, one-off build, data seed

Both end up in the same sidebar with the same logs, and the agent can start either one on demand.

Multi-root workspaces are supported: each folder can have its own taskdev.json.

Sidebar with the title-bar Open taskdev.json button next to the open config

The sidebar

Click the TaskDev icon in the Activity Bar. You get a tree grouped by project – one node per workspace folder that has a taskdev.json. The project header shows the task count and how many are running.

Each task row shows:

  • an icon (auto-picked from the name, or whatever you set in icon) that turns green while the task is running
  • the task name, plus either the first line of detail or running · 12m once started
  • a rich tooltip on hover with status, command, cwd, PID, uptime, and log path

Inline buttons appear on the task row:

  • play when the task is stopped
  • stop when it is running
  • log to open the current log file in the editor

Hovering a task row reveals Start task and Show log buttons

Clicking log opens the current run in a regular editor tab – searchable, scrollable, and the same file the agent reads over MCP.

Task log open beside the sidebar

The view title has three more actions:

  • Install MCP config – wire up agents (see below)
  • Open taskdev.json – jump to the config, or create one if it is missing
  • Refresh – re-read the config

TaskDev sidebar showing a project node with two tasks

The sidebar refreshes itself every 10 seconds while at least one task is running, every 60 seconds otherwise, and immediately when you edit taskdev.json. Multi-root workspaces show each project side by side.

MCP integration

Run TaskDev: Install MCP config from the command palette and pick which agents to wire up. Detected config files are pre-checked.

Install MCP config picker listing Windsurf, Claude Code, Cursor, Codex, and workspace-scoped configs

The MCP config is only written when this command runs. Nothing happens implicitly.

One necessary drawback is that the MCP config stores the installed extension path, which changes with each new TaskDev version. So you need to re-run TaskDev: Install MCP config after each update. TaskDev will prompt you after an upgrade, but the configs are only rewritten when you confirm in the picker.

The agent gets eight tools:

Tool Purpose
taskdev_list list tasks with status, PID, command, cwd, log path
taskdev_status status of one task or all
taskdev_control start or stop a task
taskdev_restart stop and start
taskdev_logs read recent log lines (current run, or an older run by file)
taskdev_logs_history list previous log files for a task
taskdev_add add a task (with confirmation)
taskdev_remove remove a stopped task (with confirmation)

Agents communicate with TaskDev over MCP and can manage tasks efficiently.

Typical agent loop: change code → taskdev_restart apitaskdev_logs api → read the error → fix or report.

No retry loops. No hung commands. No wasted tokens.

Trust and safety

Commands in your own taskdev.json are normal shell commands – treat the file like code, and only run it in trusted workspaces.

Agent-added tasks (taskdev_add) are sandboxed:

  • no shell chaining, redirects, variables, or subshells
  • no path traversal or arguments outside the project
  • no risky env overrides (PATH, NODE_OPTIONS, dynamic-loader vars, …)
  • only known dev command shapes – npm / pnpm / yarn scripts, dotnet, cargo, go
  • explicit confirmation before any add or remove

The agent can spin up dotnet test. It cannot invent curl ... | sh.

For the exact allow-list, env rules, runtime layout, and MCP tool reference, see security-and-config.md. For setup, see the extension README.

Feedback

Found a bug or have an idea? Open an issue at github.com/tolbxela/taskdev/issues.

Generation 1 — Standalone Models (2018–2022)

The Foundation of Modern AI Systems
When people think of tools like ChatGPT, they often assume the intelligence comes from a single powerful system that “remembers,” “reasons,” and “understands context.”

That intuition is misleading. To truly understand how modern AI systems evolved, we need to go back to Generation 1 — the era of Standalone Models, where everything began. Generation 1 (2018–2022) refers to the period defined by:

  • Large pre‑trained models like GPT, GPT‑2, and GPT‑3
  • Minimal system design around them, with no real external memory or tool integration
    These models were powerful—but fundamentally isolated. They could generate text, but they couldn’t access information, retrieve knowledge, or take actions beyond what was encoded in their training data.

The Core Idea: AI as a Stateless Engine, At the heart of Generation 1 is a critical concept. The model is stateless. Every time you send a prompt, The model processes it independently, It does not remember previous interactions and It does not learn in real time. This is true for GPT-3, Claude, Gemini, Grok. Different vendors, same architectural truth.

The 3-Layer Architecture (Simplified Mental Model)
Even in Generation 1, what you interact with (like ChatGPT) is not just a model.

3-layer
It can be understood as three distinct layers:

➡️Layer 1 — The UI Layer (Interaction Surface)
This is everything the user directly touches. It includes the chat window, the input box, the streaming response area, the conversation sidebar, the “regenerate” button, and even small touches like the copy‑to‑clipboard icon.

You see this layer in tools like ChatGPT, Claude.ai, Perplexity, Gemini, and chat panels inside apps like Cursor or Slack.

Core responsibilities

  • Capture user intent — text input, file uploads, voice, images, tool toggles, model selection
  • Render model output — token‑by‑token streaming, markdown, code blocks, math, citations
  • Create continuity — the illusion that the AI “remembers” the conversation
  • Manage session state — active chat, history navigation, drafts, error recovery
  • Surface controls — stop, regenerate, edit message, branch conversation, share, export

The non‑obvious insight
A great UI layer is what makes ChatGPT feel magical.
Under the hood, it’s the same model you could call with a simple API request.
But the experience is completely different.

➡️Layer 2 — The Orchestration Layer (The Hidden Middleware)
This is the layer most beginners never notice — and it’s the reason many “ChatGPT clones” feel broken or low‑quality. It sits between the UI and the model, quietly doing a huge amount of work the user never sees but always feels. When you send a message to ChatGPT, the text that reaches the model is not the raw message you typed. The orchestration layer transforms it first.

What this layer does

  • System prompt injection — Adds a long, carefully written instruction set that defines the assistant’s personality, tone, abilities, and safety rules.
  • Conversation history management — Decides which past messages to include, which to summarize, and which to drop as the context window fills.
  • Context window budgeting — Tracks token usage across system prompt + history + user message + expected output.
  • Safety and policy filtering — Checks your message before it reaches the model, and checks the model’s output before it reaches you.
  • Rate limiting and quotas — Enforces usage limits that show up as “You’ve reached your limit.”
  • Routing logic — Sends simple queries to cheaper models and complex ones to stronger models.
  • Telemetry and evaluation — Logging, A/B tests, quality checks, and feedback loops.

The non-obvious part: This is where AI products truly differentiate themselves. Two companies can use the same base model, yet one feels magical and the other feels clunky. Why?

Because most of the perceived quality comes from the orchestration layer — not the model.

Why “stateless model + stateful product” matters

The model behind ChatGPT is stateless. Every request is a fresh start.
It doesn’t remember your name, your last message, or that you said “use Python” earlier.

The illusion of memory and continuity is created by the orchestration layer, which replays the relevant parts of your conversation every single time.

This is the most important idea for beginners to understand:

Continuity is created by the UI + orchestration layer, not by the model.

Even today, “memory” features are built on top of the model — the model itself still forgets everything between calls.

➡️Layer 3 — The Model Layer (The Engine That Generates the Output)
This is the part everyone thinks they’re interacting with — the actual AI model. In reality, it’s only one piece of the system, but it’s the piece that does the core job: turning text in → generating text out.
At this layer, things are surprisingly simple.
What the model actually does It takes the final prompt created by the orchestration layer, and it predicts the next token Then the next, and the next, until it forms a complete response. That’s it.

  • No memory.
  • No awareness.
  • No understanding of past conversations unless they’re replayed to it.

What the model doesn’t do

  • It doesn’t remember previous chats
  • It doesn’t store facts about you
  • It doesn’t know the “session” you’re in
  • It doesn’t know what it said 10 minutes ago
  • It doesn’t know what tools the product has
    All of that lives in Layer 2, not here.

Why this layer still matters Even though the model is “just” a prediction engine, it defines the system’s raw capabilities:

  • Language fluency
  • Reasoning ability
  • Knowledge encoded during training
  • Creativity and style
  • Generalization
    A stronger model gives the orchestration layer more to work with — but the model alone is never the full product.

The key beginner insight
The model is stateless. Every request is a blank slate. It only knows what’s inside the prompt it receives right now.This is why the orchestration layer is so important: It builds the illusion of memory, personality, and continuity. The model simply reacts to whatever text it’s given.

Putting it all together

  1. Layer 1 (UI) makes the experience feel smooth
  2. Layer 2 (Orchestration) makes the experience feel intelligent
  3. Layer 3 (Model) generates the actual words

Most people think they’re talking to Layer 3.
In reality, they’re experiencing all three layers working together.

But the foundation remains:

UI + Orchestration + model
Key Takeaway for Developers

If you remember one thing, make it this, LLMs don’t remember—they are made to simulate memory through prompt construction.

This insight is essential when:
Designing AI applications
Debugging responses
Optimizing prompts
Building scalable systems
What Comes Next?

Generation 1 solved text generation. But it couldn’t:

Fetch real-time data
Ground responses in facts

That led to the next evolution:

➡️ Generation 2 — RAG (Retrieval-Augmented Generation)
Where models are no longer isolated—but connected to knowledge.

Final Thought
Generation 1 was not about building “smart assistants.”
It was about discovering that, A stateless probabilistic model, when scaled, can simulate intelligence. Everything that followed—RAG, agents, multi-agent systems—is built on top of this simple but powerful idea.

What Building a SAST Tool Taught Me About AppSec That 13 Years of Software Engineering Didn’t

I’ve been writing software professionally since 2011.

Java, C#, Kotlin, Node.js. Enterprise backends, microservices, APIs, data pipelines. I’ve shipped production code that millions of people have used without knowing it. I’ve led teams, reviewed architectures, mentored junior engineers, and done all the things that accumulate into what people call “senior software engineer.”

And yet, when I decided to transition into application security, I realised I had significant blind spots — not about how software works, but about how software fails. Specifically, how it fails in ways that attackers can exploit.

This is the final article in a series about building a SAST scanner from scratch, embedding it in CI/CD pipelines, writing custom detection rules, and managing false positives. But it’s really about what that whole process taught me about application security as a discipline — and what I wish I’d understood earlier.

I Knew How to Write Secure Code. I Didn’t Know Why It Was Secure.

Here’s an embarrassing admission: I’ve been using parameterised queries for SQL for at least a decade. I knew you were supposed to use them. I used them every time. I would have told you confidently that they prevent SQL injection.

But if you’d asked me, before I started studying AppSec seriously, to explain why they prevent SQL injection — the actual mechanism — I would have given you a hand-wavy answer about “the database handling it separately.”

Building the SQL injection detection rule forced me to get precise. I had to understand exactly what makes "SELECT * FROM users WHERE id = " + userId dangerous, what makes SELECT * FROM users WHERE id = ? with a bound parameter safe, and why the difference matters at the level of how the database parses and executes the statement.

The answer — that parameterised queries send the query structure and the data in separate messages, so the database never attempts to parse the data as SQL syntax — is not complicated. But I didn’t actually know it at that level of precision until I had to write a rule that distinguishes between the two patterns.

This was a theme throughout the project. I knew the what of secure coding from years of following conventions and best practices. Building detection rules forced me to learn the why — the actual attack mechanics that the conventions are defending against.

The lesson: Knowing the secure pattern is not the same as understanding the vulnerability. For a software engineer, the secure pattern is enough to write safe code. For an AppSec engineer, you need to understand the attack, because your job is to find it when someone else didn’t write the safe pattern.

Security Is an Adversarial Discipline

Software engineering is largely a collaborative discipline. You’re building something. The goal is for it to work. Your mental model of the system is oriented around the happy path — the flow where inputs are valid, networks are reliable, and users do what you expect.

AppSec is adversarial. The mental shift required is genuinely disorienting at first.

When I was building the JWT algorithm none rule, I had to think like someone who wants to forge authentication tokens. Not because I want to do that, but because unless I understand exactly how the attack works — what the attacker controls, what assumptions the vulnerable code makes, what the exploit chain looks like — I can’t write a rule that reliably detects it.

This is the skill that 13 years of software engineering didn’t develop: adversarial thinking. The question isn’t “does this code do what it’s supposed to do?” It’s “how could someone make this code do something it’s not supposed to do?”

The OWASP Top 10 is, at its core, a catalogue of the assumptions developers make that attackers exploit. A03 — Injection assumes that input is data, not instructions. A07 — Authentication Failures assumes that the code correctly validates identity. A02 — Cryptographic Failures assumes that encryption means the data is protected.

Every category is a place where the developer’s mental model of the system diverges from what an attacker can actually do to it. Understanding OWASP deeply means understanding those divergences — not as a checklist, but as a way of thinking.

The lesson: You can’t find vulnerabilities you can’t imagine. Developing adversarial thinking — the habit of asking “how could this go wrong for someone who wants it to go wrong” — is the most important cognitive shift in the AppSec transition.

Tools Are Amplifiers, Not Answers

Before I built my own SAST tool, I used SAST tools. And I treated them roughly like a compiler warning: something fires, I look at it, I decide whether to fix it or ignore it.

Building one changed how I think about what a SAST tool actually is.

A SAST tool is a codified set of heuristics about what vulnerable code looks like. Those heuristics are written by humans, based on human understanding of vulnerability patterns, with human decisions about confidence levels and severity ratings. The tool doesn’t know your codebase. It doesn’t know your threat model. It doesn’t know whether the finding it just generated is actually exploitable in your specific deployment context.

This sounds like a criticism. It isn’t. It’s a description of a tool’s appropriate role.

When I run Snyk or Semgrep now, I engage with the results differently than I did before. I ask: what pattern is this rule trying to catch? Is that pattern present in my code for the reason the rule assumes? Does the vulnerability the rule targets actually apply in my context? What would an attacker need to control to exploit this?

Those are AppSec questions, not DevOps questions. A DevOps mindset treats SAST output as a compliance gate. An AppSec mindset treats it as a starting point for analysis.

The lesson: A SAST scanner is a signal generator, not an oracle. The value it provides is proportional to the quality of thinking applied to its output — not to the number of findings it generates or suppresses.

False Positives Taught Me About Risk Tolerance

Every time I suppressed a finding in my own scanner, I had to make a decision: is this actually safe, and how confident am I?

That turns out to be the central skill of AppSec: structured risk assessment under uncertainty.

You almost never have complete information. You can’t always trace every data flow through a complex system. You can’t always know whether a finding is exploitable without building a proof of concept. You have to make a judgment call about whether the risk is acceptable given what you know.

What I learned from managing false positives is that risk tolerance is not a feeling — it’s a position that needs to be documented and defensible. “I suppressed this because it looked fine” is not a risk assessment. “I suppressed this because the data being processed is always from our internal configuration system and never from user input, as confirmed by tracing the call stack in lines 42–67” is a risk assessment.

The difference matters when something goes wrong. And in security, things go wrong.

The lesson: Risk assessment is a core AppSec competency, not a soft skill. Developing a structured, documented approach to risk decisions — even informal ones — is more valuable than any specific technical knowledge.

The Gap Between Writing Secure Code and Finding Insecure Code

These are related skills. They are not the same skill.

Writing secure code is a constructive activity. You know what you’re building. You apply secure patterns. You follow established conventions. The feedback loop is relatively tight — if you use parameterised queries, you know you’re not vulnerable to SQL injection there.

Finding insecure code is a forensic activity. You’re examining code you didn’t write, often without full context, looking for patterns that indicate vulnerability. The feedback loop is loose — you might flag something, triage it, determine it’s a false positive, and never know whether your triage was correct.

The cognitive skills are different. Construction requires knowing the secure pattern. Detection requires knowing the vulnerable pattern and all its variations. It requires understanding which variations are genuinely dangerous and which are contextually safe. It requires maintaining a mental model of an attacker’s perspective while reading code that was written from a developer’s perspective.

I’ve spent 13 years getting good at construction. Building this scanner was the first systematic exercise I did in detection. It was harder than I expected — not technically, but cognitively. Shifting from “I’m building this thing to work” to “I’m looking for ways this thing could be exploited” is a genuine gear change.

The lesson: AppSec is not “software engineering plus security knowledge.” It’s a different cognitive discipline that happens to use the same raw material. Senior software engineers making this transition should expect a genuine learning curve, not just a knowledge gap.

What I’d Tell Someone Starting This Transition

If you’re a software engineer moving into AppSec — or considering it — here’s what I’d tell you based on this project and the broader transition.

Build something. Reading about OWASP is useful. Reading CVE writeups is useful. Neither teaches you what building a detection rule teaches you. The act of translating “this is a vulnerability” into “this is what the vulnerable code looks like in text” forces a precision of understanding that passive learning doesn’t produce.

Study the attacks, not just the defences. Most of your software engineering career was spent learning defences — secure patterns, safe APIs, frameworks that handle the dangerous parts for you. AppSec requires understanding the attacks those defences are designed against. Read exploit writeups. Understand how CVEs actually work. Build your own vulnerable applications and attack them.

Get comfortable with ambiguity. Software engineering has right answers. Does this code compile? Does this test pass? Does this function return the correct value? AppSec often doesn’t. Is this finding exploitable? Is this suppression justified? Is this risk acceptable? These questions frequently don’t have clean answers, and developing comfort with that ambiguity is part of the transition.

Use your engineering background as a superpower, not a crutch. The thing that makes engineers valuable in AppSec is the ability to read code at scale, understand system architecture, and reason about data flows — skills most pure security professionals develop slowly. Use that. But don’t assume that understanding how the code is supposed to work means you understand how it can be broken.

Write about what you’re learning. This series started as a way to document my own thinking. Every article forced me to be more precise about something I thought I understood. The act of explaining something to someone else reveals the gaps in your own understanding faster than almost anything else.

Where This Goes Next

Building this scanner and writing this series was one project. The transition is ongoing.

The next project is taking an old Java service and doing something I haven’t done yet in this series: running Snyk against a real dependency tree on real legacy code, remediating real CVEs, and measuring the before-and-after security posture with actual metrics.

That’s a different kind of AppSec work — Software Composition Analysis rather than static analysis, dependency vulnerabilities rather than code vulnerabilities, Snyk’s recommendations rather than my own rules. But the underlying skills are the same: understand the attack, assess the risk, make a defensible decision, measure the outcome.

The transition from software engineer to AppSec engineer is not a destination. It’s an ongoing process of developing adversarial thinking, structured risk assessment, and the forensic discipline of finding what’s broken rather than building what works.

Thirteen years in, I’m still learning. That’s the right state to be in.

The full SAST tool that this series was built around is at github.com/pgmpofu/sast-tool.

If this series was useful to you — or if you’re making a similar transition and want to compare notes — I’d genuinely like to hear from you. Find me here on dev.to or connect on LinkedIn.

Python argparse: Build CLI Tools in 10 Minutes

Python argparse: Build CLI Tools in 10 Minutes

🎁 Free: AI Publishing Checklist — 7 steps in Python · Full pipeline: germy5.gumroad.com/l/xhxkzz (pay what you want, min $9.99)

The Problem with sys.argv[1]

You’ve been there. You write a quick script, hardcode a filename, then immediately need to change it. So you reach for sys.argv:

import sys

filename = sys.argv[1]
count = int(sys.argv[2])

This works — until it doesn’t. Run it without arguments and you get an IndexError. Pass a string where you expected an integer and it crashes. There’s no help text, no validation, no defaults. Anyone else who picks up your script has to read the source code to know how to run it.

argparse solves all of this. It’s in the standard library, requires no installation, and turns your script into a proper CLI tool in minutes.

The Basics: ArgumentParser

Every argparse script starts with a parser:

import argparse

parser = argparse.ArgumentParser(
    description="My CLI tool — does useful things."
)
args = parser.parse_args()

That one call to parse_args() handles everything: reading sys.argv, validating inputs, and printing help when the user passes --help.

Positional Arguments

Positional arguments are required and identified by position, not name:

parser.add_argument("filename", help="Path to the input file")
parser.add_argument("count", help="Number of items to process")

Optional Arguments (--flag and -f)

Optional arguments use -- prefix and can have short aliases:

parser.add_argument("--output", "-o", help="Output file path", default="output.txt")
parser.add_argument("--verbose", "-v", help="Enable verbose logging", action="store_true")

Type Validation: No More Manual Casting

Instead of int(sys.argv[1]) wrapped in a try/except, let argparse handle it:

parser.add_argument("--count", type=int, default=10, help="Number of items")
parser.add_argument("--rate", type=float, default=1.5, help="Processing rate")
parser.add_argument(
    "--format",
    choices=["json", "csv", "txt"],
    default="json",
    help="Output format"
)

If a user passes --count hello, argparse prints a clean error message and exits — no stack trace, no confusion.

Required Arguments, nargs, and Lists

Required Optional Arguments

parser.add_argument("--title", required=True, help="Article title (required)")

Accepting Multiple Values

# One or more values: --tags python beginner tutorial
parser.add_argument("--tags", nargs="+", help="One or more tags")

# Zero or more values: --tags (empty is fine)
parser.add_argument("--tags", nargs="*", help="Zero or more tags")

The result is a Python list you can iterate directly:

args = parser.parse_args()
for tag in args.tags:
    print(tag)

Boolean Flags: store_true and store_false

Boolean flags don’t take a value — their presence or absence is the value:

parser.add_argument("--dry-run", action="store_true", help="Simulate without writing")
parser.add_argument("--no-color", action="store_false", dest="color", help="Disable color output")

Usage:

python publish.py --dry-run        # args.dry_run is True
python publish.py                  # args.dry_run is False
python publish.py --no-color       # args.color is False

Subcommands: One Tool, Many Commands

Real CLI tools like git, docker, and pip use subcommands. add_subparsers() gives you the same structure.

parser = argparse.ArgumentParser(description="Publish queue manager")
subparsers = parser.add_subparsers(dest="command", required=True)

# `publish` subcommand
publish_parser = subparsers.add_parser("publish", help="Publish the next article in queue")
publish_parser.add_argument("--dry-run", action="store_true", help="Simulate without publishing")

# `list` subcommand
list_parser = subparsers.add_parser("list", help="Show the publish queue")
list_parser.add_argument("--format", choices=["table", "json"], default="table")

Now args.command tells you which subcommand was chosen, and each subcommand has its own arguments.

The --verbose / -v Pattern

A common pattern is using --verbose to set the logging level at runtime:

import argparse
import logging

parser = argparse.ArgumentParser()
parser.add_argument("--verbose", "-v", action="store_true", help="Enable debug logging")
args = parser.parse_args()

logging.basicConfig(
    level=logging.DEBUG if args.verbose else logging.INFO,
    format="%(levelname)s: %(message)s"
)

log = logging.getLogger(__name__)
log.info("Starting...")
log.debug("This only shows with --verbose")

Complete Example: Publish Queue CLI

Here’s a working CLI for managing an article publish queue — the same pattern used in the full pipeline.

#!/usr/bin/env python3
"""
publish_queue.py — CLI for managing the article publish queue.
Usage: python publish_queue.py <command> [options]
"""

import argparse
import json
import logging
import sys
from pathlib import Path

QUEUE_FILE = Path("queue.json")


def load_queue() -> list[dict]:
    if not QUEUE_FILE.exists():
        return []
    return json.loads(QUEUE_FILE.read_text())


def save_queue(queue: list[dict]) -> None:
    QUEUE_FILE.write_text(json.dumps(queue, indent=2))


def cmd_list(args: argparse.Namespace) -> None:
    queue = load_queue()
    if not queue:
        print("Queue is empty.")
        return
    for i, article in enumerate(queue, 1):
        status = "[published]" if article.get("published") else "[pending]  "
        print(f"{i}. {status} {article['title']} ({', '.join(article.get('tags', []))})")


def cmd_add(args: argparse.Namespace) -> None:
    queue = load_queue()
    article = {
        "title": args.title,
        "tags": args.tags or [],
        "published": False,
    }
    queue.append(article)
    save_queue(queue)
    logging.info("Added: %s", args.title)
    print(f"Added '{args.title}' to queue. Total: {len(queue)} articles.")


def cmd_publish(args: argparse.Namespace) -> None:
    queue = load_queue()
    pending = [a for a in queue if not a.get("published")]
    if not pending:
        print("No pending articles.")
        return
    next_article = pending[0]
    if args.dry_run:
        print(f"[DRY RUN] Would publish: {next_article['title']}")
        return
    next_article["published"] = True
    save_queue(queue)
    print(f"Published: {next_article['title']}")
    logging.info("Published: %s", next_article["title"])


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        prog="publish_queue",
        description="Manage your article publish queue.",
    )
    parser.add_argument(
        "--verbose", "-v",
        action="store_true",
        help="Enable debug logging",
    )

    subparsers = parser.add_subparsers(dest="command", required=True)

    # list
    list_parser = subparsers.add_parser("list", help="Show the publish queue")
    list_parser.set_defaults(func=cmd_list)

    # add
    add_parser = subparsers.add_parser("add", help="Add an article to the queue")
    add_parser.add_argument("--title", required=True, help="Article title")
    add_parser.add_argument("--tags", nargs="*", help="Tags for the article")
    add_parser.set_defaults(func=cmd_add)

    # publish
    publish_parser = subparsers.add_parser("publish", help="Publish the next pending article")
    publish_parser.add_argument("--dry-run", action="store_true", help="Simulate without writing")
    publish_parser.set_defaults(func=cmd_publish)

    return parser


def main() -> None:
    parser = build_parser()
    args = parser.parse_args()

    logging.basicConfig(
        level=logging.DEBUG if args.verbose else logging.INFO,
        format="%(levelname)s: %(message)s",
    )

    args.func(args)


if __name__ == "__main__":
    main()

--help Output

$ python publish_queue.py --help
usage: publish_queue [-h] [--verbose] {list,add,publish} ...

Manage your article publish queue.

positional arguments:
  {list,add,publish}
    list              Show the publish queue
    add               Add an article to the queue
    publish           Publish the next pending article

options:
  -h, --help          show this help message and exit
  --verbose, -v       Enable debug logging

$ python publish_queue.py add --help
usage: publish_queue add [-h] --title TITLE [--tags [TAGS ...]]

options:
  -h, --help           show this help message and exit
  --title TITLE        Article title
  --tags [TAGS ...]    Tags for the article

Running It

# Add articles to the queue
python publish_queue.py add --title "Python argparse guide" --tags python beginners tutorial
python publish_queue.py add --title "Automate your workflow" --tags python automation

# List the queue
python publish_queue.py list
# 1. [pending]   Python argparse guide (python, beginners, tutorial)
# 2. [pending]   Automate your workflow (python, automation)

# Publish next (dry run first)
python publish_queue.py publish --dry-run
# [DRY RUN] Would publish: Python argparse guide

python publish_queue.py publish
# Published: Python argparse guide

# Check updated queue with debug logging
python publish_queue.py list --verbose

Key Patterns to Remember

Pattern When to use it
type=int / type=float Any numeric input
choices=[...] Fixed set of valid values
required=True Mandatory optional args
nargs="+" / nargs="*" Lists of values
action="store_true" Boolean flags
add_subparsers() Multi-command tools
set_defaults(func=...) Dispatch to subcommand functions

What You Get for Free

Every argparse-based script automatically has:

  • --help / -h — generated from your help= strings
  • Type validation — with clear error messages, no tracebacks
  • Default values — documented in the help output
  • Usage line — auto-generated from your argument definitions

No third-party libraries. No pip install. Just the standard library.

The publish queue CLI in the full pipeline uses argparse for –list, –add, and –publish: germy5.gumroad.com/l/xhxkzz — pay what you want, min $9.99.

Further Reading

  • Your First Automated Python Script That Validates and Runs Itself
  • Python logging: Stop Using print() in Your Automation Scripts
  • How to Schedule Python Scripts with Cron: A Beginner’s Complete Guide