Packaging, signing and the dev runner
A plugin is distributed as a single .fgpl file. This page describes the format, the fgpl_tool command line that builds and checks packages, how signing works, and the local dev runner. For the manifest see the full reference; for installing and updating see Publishing and updates.
The .fgpl format
Section titled “The .fgpl format”A .fgpl file is a plain ZIP archive:
hello.fgpl├── manifest.json (required, at the root)├── main.py (the runtime file: runtime.entry or runtime.module)├── assets/icon.png└── assets/star.svgRules the host enforces when it opens a package (any violation rejects the whole package):
| Area | Rule |
|---|---|
| Manifest | manifest.json at the archive root, up to 256 KiB, valid per the reference |
| Listing | Every other file must be listed in manifest.files with a matching sha256-<base64> hash, and every listed file must exist. Unlisted extra files are an error. manifest.json itself must not be listed: the parser does not notice, but then the package is rejected with 'manifest.json' is listed but missing |
| Runtime file | runtime.entry (Python) or runtime.module (Wasm) must be in the package |
| Icon | icon must be a real PNG (PNG signature checked), up to 256 KiB. This is checked when the package is opened, not by the manifest parser; contributes.icons files are not checked here (see Runtime files and icons) |
| Paths | At most 16 directory levels; the whole entry name is at most 255 bytes; each segment is 1–128 printable ASCII characters, is not . or .., does not start or end with a space and does not end with a dot, and contains none of \ : * ? " < > |; names are compared case-insensitively, so A.txt and a.txt are duplicates; a name cannot be both a file and a directory |
| Entries | At most 4096 entries; one file up to 128 MiB; all files together up to 256 MiB unpacked; the archive up to 300 MiB; each file’s compression ratio is limited (about 100x). The in-app file chooser accepts up to 64 MiB |
| ZIP features | Only methods stored and deflate. No encryption, ZIP64, multi-disk, archive comments, symlinks or special files, data outside entries (gaps/overlaps), or mismatching local/central headers. CRC-32 is verified |
| Forbidden content | Native or script file types are refused by extension (.so .dll .exe .dylib .dex .jar .class .apk .sh .bat .cmd .ps1 .com .scr .msi .node .pyd .pyc .pyo .elf .app .vbs) and by content (ELF, Windows PE MZ, Mach-O, Java class, DEX, nested ZIP, and any file starting with #! unless it ends in .py) |
| Python | The entry file must be UTF-8 without NUL bytes |
| Wasm | Imports limited to freshgram::call and the permitted WASI functions; must export memory and either fg_alloc+fg_init+fg_on_event or _start |
| Signature | If present, it must verify (below) |
What a plugin can read at run time are the files of the package (read-only), plus its private storage; there is no other file access.
Runtime files and icons
Section titled “Runtime files and icons”- Python plugins ship their
.pysources. The interpreter itself is a separate downloadable runtime bundle managed by the app; the SDK module isfreshgram(see the Python quickstart). Extra.pymodules in the package are fine; compiled extensions are not. - Wasm plugins ship one
.wasm(see the Wasm quickstart). Compiled modules are cached by the app after the first start. - Icons:
iconis the plugin’s picture (PNG, up to 256 KiB). Menu icons are either one of the built-in names or a PNG/SVG declared incontributes.icons. The package validator does not look inside those files, but the app silently ignores a custom icon larger than 64 KiB (no error), so keep them small.
Building a package: fgpl_tool
Section titled “Building a package: fgpl_tool”This section is a short summary; the full guide to the tool, including how to build it and troubleshoot it, is fgpl_tool.
fgpl_tool is built from Telegram/plugins_host (CMake target fgpl_tool):
cmake -S Telegram/plugins_host -B build -D CMAKE_BUILD_TYPE=Releasecmake --build build --target fgpl_toolCommands useful to plugin authors:
| Command | What it does |
|---|---|
fgpl_tool pack <directory> <output.fgpl> [--key <name>.key] |
Builds a package from a directory |
fgpl_tool verify <package.fgpl> |
Opens a package with the same checks the app uses and prints a summary |
fgpl_tool keygen <name> |
Creates a signing key pair |
fgpl_tool run <directory or .fgpl> [options] |
Runs the plugin locally (below) |
fgpl_tool hash <file> |
Prints "sha256": "...", "size": N for a file |
(sign-runtime and pack-runtime are for the Python runtime bundle and are not needed for plugins.) With no arguments, an unknown command or the wrong number of arguments the tool prints its usage and exits with code 64.
fgpl_tool pack ./hello hello.fgplpack reads the directory recursively (regular files only), and:
- takes
manifest.jsonfrom the directory; - throws away any
filesandsignaturekeys in it and computes a freshfilesmap (path →sha256-<base64>) for every other file, sorted by path; - optionally signs it;
- writes
manifest.json(canonical JSON: sorted keys, no whitespace) first, then the files, into a ZIP (deflate where it makes the file smaller); - re-opens the result with the same validator as the app, and refuses to write a package that would not be accepted (exit code 2, with the list of problems).
So you do not write files by hand, and everything in the directory ends up in the package: keep the directory clean (no .git, __pycache__, editor files). Exit codes: 0 ok, 1 I/O or key error, 2 the package is invalid, 64 wrong arguments (usage is printed).
On success it prints a summary (id, version, runtime, signature, requested permissions with their reasons, risk notes, file count) — the same information verify prints.
verify
Section titled “verify”fgpl_tool verify hello.fgplExit code 2 and the list of problems if the package is not valid; otherwise the summary. Run it in CI before publishing.
Signing
Section titled “Signing”A signature lets users (and you) tell that a package comes from the same publisher as before and was not modified. It is optional; an unsigned package installs too, with a warning.
Algorithm
Section titled “Algorithm”- Ed25519 (
signature.alg = "ed25519"). - The signed message is the canonical JSON of the manifest without its
signaturekey: keys sorted (by UTF-16 code units), no insignificant whitespace, standard JSON string escaping. Becausefilesholds the SHA-256 of every file, the signature covers the whole package. Re-formatting the manifest file does not break it, because the host canonicalizes the parsed document before verifying. - The manifest stores
publicKey(32 raw bytes) andvalue(64 raw bytes), both standard Base64.
Key format
Section titled “Key format”fgpl_tool keygen mykey writes mykey.key: the 32-byte Ed25519 seed as one line of Base64 (44 characters) and a newline. It prints the public key (Base64) and the fingerprint. Keep the .key file secret and back it up; losing it means you cannot publish updates under the same publisher, and leaking it lets anyone impersonate you.
fgpl_tool keygen mykey# private key: mykey.key (keep it secret)# public key: <base64 of the 32-byte key># fingerprint: 1a2b 3c4d 5e6f 7a8b 9c0d 1e2f 3a4b 5c6dHow to sign
Section titled “How to sign”fgpl_tool pack ./hello hello.fgpl --key mykey.keypack signs with the key and adds the signature object to the manifest. --key takes the path to the key file. To re-sign, just run pack again; an existing signature is ignored and replaced. Sign last: any change to the manifest or to any file after signing invalidates the signature (the package is then rejected).
What “publisher” means
Section titled “What “publisher” means”The publisher is the fingerprint of the public key: the first 16 bytes of the SHA-256 of the 32-byte public key, as 32 hex digits in eight groups of four (1a2b 3c4d …). An unsigned package has no publisher.
What it is used for:
- Install dialog: any publisher other than the official freshGram key is shown with the warning “Unofficial plugin. Install it only if you trust its source.” Packages signed with the official key are labeled “Official plugin”. An unsigned package is also unofficial.
- Updates: the host remembers the publisher of the installed version. If a new version has a different publisher (including going from signed to unsigned), the user is asked “The publisher of this plugin has changed. Install it anyway?” (details).
There is no certificate authority: the signature proves continuity (same key as before), not identity. Publish your fingerprint on your homepage so users can compare it.
The dev runner
Section titled “The dev runner”fgpl_tool run loads a plugin directory (or a .fgpl) into the real plugin host and drives it with a small fake app: three chats, an in-memory message store, a clock you can advance, and a mock HTTP layer. Every effect the plugin has on the app is printed. No Telegram account is needed.
fgpl_tool run ./hello --runtime ./runtime --no-sandbox --grant messages.read,ui.commandRun a directory and the tool packs it in memory exactly like pack (unsigned). Then it installs it (unsigned packages and any publisher are accepted, and downgrades allowed), grants permissions, enables the plugin and reads commands from standard input, one per line (a REPL), or from a file with --script.
Options
Section titled “Options”| Option | Meaning |
|---|---|
<target> |
(positional, required) a directory with manifest.json, or a .fgpl file |
--script <file> |
Read the commands from a file instead of standard input; the run ends at end of file |
--runtime <dir> |
A runtime directory with python/ and sdk/ (as in the downloaded bundle). Optional in a tool built from the client sources, which falls back to its built-in CPython and SDK; needed for a tool that was moved, or to test against a downloaded bundle. Can also be set with FGPL_RUNTIME_DIR. Wasm plugins do not need it |
--host <path> |
The plugin host executable. Default: fgpl_plugin_host next to fgpl_tool, or $FGPL_HOST |
--no-sandbox |
Use fgpl_plugin_host_unsandboxed (a test build of the host without the OS sandbox) so it runs anywhere. Only for development |
--state <dir> |
Keep the plugin’s installed files and storage in this directory between runs. Without it a temporary directory is used and deleted on exit. With it, the plugin is removed and reinstalled at every start, but its data directory stays |
--folder <dir> |
The folder granted for fs.folder. Without it fs.folder is not granted |
--grant a,b |
Grant only these permission ids (comma separated). Default: every requested permission |
--deny a,b |
Do not grant these ids |
--app X.Y.Z |
The client version the fake app reports (default 7.5.10); checks compat.freshgram |
--account <id> |
The account name used for events (default dev) |
Environment: FGPL_HOST, FGPL_RUNTIME_DIR. A host that is missing, or a Python plugin without a runtime, is reported on start.
Exit codes
Section titled “Exit codes”0 ok · 1 unreadable file, bad --app value, or missing host/runtime · 2 the package is invalid or could not be installed · 3 finished, but some commands failed or the plugin reported failures · 64 usage (unknown option, option without a value, a second target or none; the usage text is printed).
REPL commands
Section titled “REPL commands”Lines starting with # are comments. Values: 12, true, null, text, "quoted text" (with \n and \" escapes), @file (bytes of a file), and key:=<json> for a JSON value.
| Command | Effect |
|---|---|
event <type> [key=value ...] |
Deliver an event, for example event chat.opened chat=1. Prints queued/delivered/skipped/failed counts |
msg new <chat> <text> [from=N] [out=true] |
Add a message to a chat and deliver message.new. from is the sender id (default: the chat id in a user chat, otherwise 100). The presence of the out key marks the message as outgoing (from_kind self, out: true); its value is not read, so leave the key out for an incoming message |
msg add <chat> <text> [from=N] [out=true] |
Add a message the plugin can read (history) without an event; takes the same from and out options and prints [run] message <id> |
chat add <id> <title> [type] |
Add a chat (user, group, channel, …). Built-in: 1 Alice (user), 2 Group, 3 News (channel) |
command <name> [args] [chat=N] |
Run a /command of the plugin |
menu <surface> <id> [key=value ...] |
Click a menu item |
settings open|click|change <id> [value] |
Interact with the plugin’s settings page |
dialog click|change|close <id> [value] |
Interact with a dialog |
window <window> click|change|close <id> [value] |
Interact with a plugin window |
toast click|close <id> [reason] |
Interact with a toast |
outgoing <chat> <text> |
Run the outgoing hooks on a text and print the result |
timer advance <ms> |
Move the plugin clock forward so that timers fire |
mock <url-prefix> <status> <body|@file> |
Answer net.fetch calls to URLs with this prefix |
grant <permission> / revoke <permission> |
Change granted permissions at run time (must be requested in the manifest) |
storage |
Print every key of the plugin’s storage |
info |
Print status, event/failure counters and which permissions are granted |
wait <ms> |
Let the plugin run (up to 600 000 ms) |
help, quit / exit |
Output lines are prefixed: [run] for the runner, [host] for host messages (a line starting failure counts as a plugin failure), [<plugin id>] for the plugin’s own print/log output, and [app] for what the plugin asked the app to do (toasts, dialogs, settings pages, windows, badges). UI calls are printed as JSON.
After a command that wakes the plugin (an event, a message, a command, a menu click, a UI interaction, a timer advance, wait) the runner waits for the plugin to go idle for at most 10 seconds; if it is still working it prints [run] the plugin is still busy after 10 seconds and goes on to the next command (at start, the same wait prints [run] the plugin is still starting after 10 seconds). The same wait happens once more at the end of the run, before the host is shut down.
Example script
Section titled “Example script”chat add 10 "Dev group" groupmsg new 10 "hello there"command hello chat=10storagemock https://api.example.com/ 200 "{\"ok\":true}"timer advance 60000quitfgpl_tool run ./hello --runtime ./runtime --script test.txt --state ./.dev-stateThe run prints the delivery counts of message.new, whatever the /hello command did ([app] lines), the keys of storage, and ends with exit code 0 if nothing failed.
Release checklist
Section titled “Release checklist”- Bump
version(andstorage.schemaif the stored data layout changed). fgpl_tool pack ./hello hello-1.2.0.fgpl --key mykey.key.fgpl_tool verify hello-1.2.0.fgpl— check the printed permissions and publisher fingerprint.- Smoke-test with
fgpl_tool run hello-1.2.0.fgpl --script smoke.txt. - Publish the file (a Telegram document with the
.fgplextension opens the install dialog in the app).
