Canonical instructions for every coding agent working in this repo (Claude
Code, Codex, Cursor, Zed, …). CLAUDE.md is only a pointer that imports this
file — edit AGENTS.md, never CLAUDE.md.
tweb is a full-featured Telegram web client (https://web.telegram.org/k/) built with Solid.js and TypeScript. It implements Telegram's MTProto protocol directly in the browser (no third-party API wrappers). The codebase is large (~100k+ lines excluding vendor), mature, and highly performance-oriented.
Author: Eduard Kuzmenko. License: GPL v3.
| Layer | Technology |
|---|---|
| UI Framework | Solid.js (custom fork in src/vendor/solid/) |
| Language | TypeScript 5.7 |
| Build | Vite 5 |
| CSS | SCSS (sass) |
| Testing | Vitest |
| Package Manager | pnpm 11 |
| Protocol | MTProto (custom implementation) |
| Storage | IndexedDB + CacheStorage + localStorage |
| Workers | SharedWorker + ServiceWorker |
pnpm install
pnpm start # Dev server on :8080
pnpm build # Production build → dist/
pnpm test # Run tests (Vitest)
pnpm lint # oxlint on src/ (config: .oxlintrc.json)
pnpm lint:fix # Same, with auto-fixDebug query params: ?test=1 (test DCs), ?debug=1 (verbose logging), ?noSharedWorker=1 (disable shared worker).
Launch an authorized local preview with bash scripts/start-preview.sh (never
plain vite) — it mints a fresh per-preview auth + picks a free port. Flags and
details: see the script header. .claude/launch.json wires it into Claude
Code's preview pane; other agents run the script directly and open the printed
URL with their own browser tooling.
src/
├── components/ # Solid.js UI components (.tsx)
│ ├── chat/ # Chat bubbles, topbar, sidebars
│ ├── popups/ # Modal/popup components
│ ├── mediaEditor/ # Media editing UI
│ └── ... # 200+ feature folders
├── lib/
│ ├── appManagers/ # 55+ domain managers (chats, users, messages, etc.)
│ ├── mtproto/ # MTProto protocol implementation
│ ├── storages/ # IndexedDB/localStorage wrappers
│ ├── rootScope.ts # Global event emitter & app context
│ └── mainWorker/ # Background worker logic
├── stores/ # Solid.js reactive stores (13 stores)
├── helpers/ # 145+ utility functions
├── hooks/ # Solid.js hooks
├── pages/ # Auth pages (login, signup, etc.)
├── config/ # App constants, state schema, emoji, currencies
├── environment/ # Browser feature detection (39 modules)
├── scss/ # Global stylesheets
├── vendor/ # Third-party forks (solid, solid-transition-group)
├── scripts/ # Build & codegen scripts
└── tests/ # Test files
Always use these aliases instead of relative paths:
@components/* → src/components/
@helpers/* → src/helpers/
@hooks/* → src/hooks/
@stores/* → src/stores/
@lib/* → src/lib/
@appManagers/* → src/lib/appManagers/
@environment/* → src/environment/
@config/* → src/config/
@vendor/* → src/vendor/
@layer → src/layer.d.ts (MTProto API types)
@types → src/types.d.ts (utility types)
@/* → src/
// Solid.js resolves to the custom fork:
solid-js → src/vendor/solid
solid-js/web → src/vendor/solid/web
solid-js/store → src/vendor/solid/storeNon-obvious rules — these differ from common defaults:
- No space after keywords:
if(cond),for(...),while(...),switch,catch— notif (cond) - No space inside
{}/[]:{a: 1}and[1, 2]— not{ a: 1 } - No trailing comma anywhere
- No space before function paren:
function foo() return awaitrequired inside try/catch (typescript/return-awaitinerror-handling-correctness-onlymode); elsewhere return the promise directly (convention, not linted)
Standard defaults, also enforced: single quotes, LF + final newline, no trailing whitespace, no tabs, max 2 blank lines, prefer-const. 2-space indent comes from .editorconfig (the linter only bans tabs).
strict: truebutstrictNullChecks: falseandstrictPropertyInitialization: falseuseDefineForClassFields: false— important for class field behaviorjsxImportSource: solid-js— JSX is Solid.js, not React- MTProto types live in
src/layer.d.ts(664KB, auto-generated); import from@layer - Utility types (AuthState, WorkerTask, etc.) live in
src/types.d.ts; import from@types - Global types available everywhere:
PeerId,UserId,ChatId,BotId,DocId,Long,Icon,ApiError,ErrorType,MaybePromise<T>. Defined insrc/global.d.ts.
Components are in .tsx files. Props typed inline. Use classNames() helper for class composition:
import {JSX} from 'solid-js';
import classNames from '@helpers/string/classNames';
export default function MyComponent(props: {
class?: string,
children: JSX.Element
}) {
return (
<div class={classNames('my-class', props.class)}>
{props.children}
</div>
);
}Scoped styles use .module.scss files. Import as styles:
import styles from '@components/chat/bubbles/service.module.scss';
// Usage: <div class={styles.wrap}>Stores in src/stores/ use createRoot + createSignal and export a hook:
import {createRoot, createSignal} from 'solid-js';
import rootScope from '@lib/rootScope';
const [value, setValue] = createRoot(() => createSignal(initialValue));
rootScope.addEventListener('some_event', setValue);
export default function useValue() {
return value;
}Business logic lives in AppManager subclasses in src/lib/appManagers/. They communicate via rootScope events and are accessed via rootScope.managers:
import {AppManager} from '@appManagers/manager';
export class AppSomethingManager extends AppManager {
protected after() {
// Initialization after state loaded
this.apiUpdatesManager.addMultipleEventsListeners({...});
}
}All interaction with MTProto MUST go through the app managers. Managers wrap the raw APIs with a nicer interface, a caching layer, and the side-effect handling (saving peers, dispatching updates) the rest of the app expects. Managers are the source of truth.
Strict rule — never call apiManager.invokeApi* directly from UI / component code. Even though rootScope.managers.apiManager.invokeApi(...) runs in the worker (it goes through the manager proxy), it bypasses every wrapper: no caching, no saveApiPeers, no processUpdateMessage, no dedup with the rest of the app. If a component needs MTProto data, add (or extend) a method on the relevant app*Manager and call THAT from the UI:
// ❌ wrong — UI making a raw MTProto call
const result = await rootScope.managers.apiManager.invokeApi('messages.getSearchResultsCalendar', {...});
// ✅ right — manager method wraps the call, UI invokes by domain intent
const result = await rootScope.managers.appMessagesManager.getSearchResultsCalendar({peerId, filter, offsetDate});Invoking MTProto methods (inside a manager) is done via:
// invoke normally
await this.apiManager.invokeApi('payments.checkCanSendGift', {gift_id: gift.id})
// invoke with deduplication
await this.apiManager.invokeApiSingle('payments.checkCanSendGift', {gift_id: gift.id})
// invoke and do something with the result (only available inside managers)
return this.apiManager.invokeApiSingleProcess({
method: 'some.method',
params: {...},
processResult: (result) => {
// when the result type has {chats, users} fields, use this method to save them
this.appPeersManager.saveApiPeers(result);
// when the result is `Updates`, use this method to handle them
this.apiUpdatesManager.processUpdateMessage(result);
}
});Global event bus and context. Available everywhere:
import rootScope from '@lib/rootScope';
rootScope.addEventListener('premium_toggle', handler);
rootScope.managers.appChatsManager.getChat(chatId);IMPORTANT: rootScope.managers.* are asynchronous proxies to a shared worker. Every manager method returns a Promise, even if the manager's own methods seem synchronous.
Strict rule — never call navigator.mediaDevices.getUserMedia directly when you need a camera or microphone. Use getStream from @lib/calls/helpers/getStream. It is the single chokepoint for every getUserMedia in the app (calls, voice notes, round-video notes), so two things happen for free:
- It honours the device the user picked in Settings → Speakers and Camera (
appSettings.callDevices.cameraId/microphoneId). - It self-heals a stale selection: if the saved device is gone it strips the
deviceId, clears the now-deadcallDevices.*entry, and retries on the OS default — incrementally, so a still-valid device survives when only the other one is stale.
import getStream from '@lib/calls/helpers/getStream';
// ❌ wrong — ignores the chosen device, no fallback
const stream = await navigator.mediaDevices.getUserMedia({video: true, audio: true});
// ✅ right — selected device + self-healing fallback
const stream = await getStream({video, audio});For the standard call-tuned video/audio constraints (which already inject the selected device), build them with getVideoConstraints() / getAudioConstraints() from the same folder; otherwise pass your own constraints and getStream handles acquisition + device fallback.
Shared blob URLs (thumbnails, avatars, backgrounds — anything minted by the worker) are revocable: the worker's LRU may evict and revoke them at any time (30 s grace after eviction). The rule is not enforced by types or lint, and getting it wrong fails rarely and unreproducibly — so pick the right case consciously:
- Rendering an image (
<img>, canvas, one-shot CSS): just use the URL from the manager (downloadMediaURL/cacheContext.url). No bookkeeping — a decoded bitmap survives revocation, and a later re-render simply re-requests a fresh URL. - Handing the URL to something that will RESOLVE it later — a playing or
looping media element (seek/loop re-read the blob), MediaSession artwork,
long-lived CSS background: take
pinObjectURL(url)from@helpers/objectUrland call the returned unpin in the consumer's cleanup (usuallymiddleware.onClean). A missing pin breaks playback only after the URL is evicted — i.e. almost never in testing, occasionally in production. - Tab-local one-off URL (editor previews, probes, worklet scripts): create
it through an
ObjectURLScopeand dispose the scope. Never pass a tab-minted blob URL to the worker (setSharedObjectURLaccepts worker-minted URLs only — a tab's URL dies with the tab).
All MTProto types come from @layer:
import {Message, Chat, User, InputPeer} from '@layer';- Global styles in
src/scss/ - Component-scoped styles in
.module.scssnext to component files - BEM-like class naming convention
- CSS variables used for theming
| File | Purpose |
|---|---|
src/index.ts |
App entry point, account/auth init |
src/lang.ts |
All i18n strings (232KB) |
src/layer.d.ts |
MTProto API types (auto-generated, 664KB) |
src/types.d.ts |
Utility/app types |
src/global.d.ts |
Global interface augmentations |
src/config/state.ts |
Application state schema |
src/config/app.ts |
App constants |
src/lib/rootScope.ts |
Global event emitter |
vite.config.ts |
Build configuration |
.oxlintrc.json |
oxlint config (style rules via @stylistic/eslint-plugin jsPlugin) |
- Never duplicate code. Before adding logic, helpers, components, styles, or constants, search the codebase for an existing implementation and reuse or extend it. Every final review must explicitly check the completed change for duplicated code and remove any duplication found.
- After every context compaction, reread this entire
AGENTS.mdbefore continuing work. A compacted context or summary does not replace the canonical instructions in this file.
(Style rules are in "Code Style"; the import-alias, invokeApi-from-UI, and
getUserMedia-via-getStream rules are in "Path Aliases", "App Managers", and
"Key Patterns → Media devices" — not repeated here.)
- Never commit on your own initiative — only when explicitly asked. Iterating on a feature must not produce a trail of commits: keep the work in the working tree, and when asked to commit, fold the whole feature into ONE commit (directly on master, no feature branch) unless told otherwise.
- Do not add
oxlint-disable(or legacyeslint-disable) comments without a reason - Never hand-edit or manually run
format-langto regeneratesrc/scripts/out/langPack.strings— it is auto-generated fromlang.ts/langSign.tsby the Vite-wired lang watcher (watch-lang.js) on dev-server start, on everylang.tschange, and on build. Edit the lang.tssource only. - Do not import from
reactor use React patterns — this is Solid.js - Do not use heavy CSS selectors (deep descendant chains, universal
*, expensive attribute matchers,:not()with complex arguments) — prefer a dedicated class on the target element - Never add a blocking MTProto request on the chat-open path.
ChatInput.finishPeerChange(and any siblingfinishPeerChangein the chat stack) awaits aPromise.allbefore unfreezing the input — every entry there is paid in chat-open latency. Do NOT addappPrivacyManager.getGlobalPrivacySettings,appProfileManager.getProfilefor unrelated peers, freshaccount.*fetches, or any new uncached round-trip into that batch. If a feature needs server data, either: (a) read it from a manager-side cache that's already kept warm (e.g.apiManagerProxy.getAppConfig,getPrivacyafter preload, cached userFull), (b) fetch it lazily AFTER the chat renders and reconcile via an event (peer_full_update,privacy_update, custom dispatched event) + aupdate*helper, or (c) preload at app startup and gate viarootScope.premium-style cached flags. The same rule holds forappImManager.setPeerlisteners andsetChatListeners— keep them event-driven, neverawait managers.*for a per-peer hot-path render.
pnpm test # all tests
pnpm test src/tests/foo # specific test fileVitest config: threads: false, globals: true, jsdom environment, setup in src/tests/setup.ts.
Skills and commands live in the repo as the single source; per-agent integration only points at them:
- Skills —
.claude/skills/*/SKILL.md(standard Agent Skills format:name+descriptionfrontmatter, optional bundled scripts). Claude Code discovers them automatically. Codex discovers them via symlinks in~/.codex/skills/pointing at these directories. An agent without skill auto-discovery should still open the matching SKILL.md and follow it when a task fits its description. Paths inside skills are relative to the repo root. - Commands / prompts —
.claude/commands/*.mdare slash-command prompt files ($ARGUMENTS-style placeholders); Codex reads them via symlinks in~/.codex/prompts/. Exception:forge.mdis Claude-Code-only — it depends on a Claude statusline usage gate and will not work elsewhere. - Tool-name mapping — skill/command texts may name Claude Code tools.
Substitute your agent's equivalent: "Agent tool" / "subagent" /
Explore→ spawn a sub-task or do the search inline; browser-panepreview_start→ runbash scripts/start-preview.shand open the printed URL;AskUserQuestion→ ask in chat. .claude/launch.json(preview servers) and.claude/settings.local.json(permissions) are Claude-Code-specific; the Codex counterpart is~/.codex/config.toml.
Re-create the Codex symlinks on a new machine (run from the repo root):
mkdir -p ~/.codex/skills ~/.codex/prompts
for s in graphify run-build tg-port-feature tweb-bugs tweb-mtproto-debug; do
ln -sfn "$(pwd)/.claude/skills/$s" ~/.codex/skills/$s
done
for c in planner task refactor-popup-procedural; do
ln -sfn "$(pwd)/.claude/commands/$c.md" ~/.codex/prompts/$c.md
donePrefix every shell command with rtk, including each command inside &&
chains: rtk git add . && rtk git commit -m "msg". RTK applies a filter when it
has one, otherwise passes through unchanged — so it is always safe.