Skip to content

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.

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.svg

Rules 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.

  • Python plugins ship their .py sources. The interpreter itself is a separate downloadable runtime bundle managed by the app; the SDK module is freshgram (see the Python quickstart). Extra .py modules 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: icon is the plugin’s picture (PNG, up to 256 KiB). Menu icons are either one of the built-in names or a PNG/SVG declared in contributes.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.

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):

Terminal window
cmake -S Telegram/plugins_host -B build -D CMAKE_BUILD_TYPE=Release
cmake --build build --target fgpl_tool

Commands 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.

Terminal window
fgpl_tool pack ./hello hello.fgpl

pack reads the directory recursively (regular files only), and:

  1. takes manifest.json from the directory;
  2. throws away any files and signature keys in it and computes a fresh files map (path → sha256-<base64>) for every other file, sorted by path;
  3. optionally signs it;
  4. writes manifest.json (canonical JSON: sorted keys, no whitespace) first, then the files, into a ZIP (deflate where it makes the file smaller);
  5. 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.

Terminal window
fgpl_tool verify hello.fgpl

Exit code 2 and the list of problems if the package is not valid; otherwise the summary. Run it in CI before publishing.

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.

  • Ed25519 (signature.alg = "ed25519").
  • The signed message is the canonical JSON of the manifest without its signature key: keys sorted (by UTF-16 code units), no insignificant whitespace, standard JSON string escaping. Because files holds 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) and value (64 raw bytes), both standard Base64.

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.

Terminal window
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 5c6d
Terminal window
fgpl_tool pack ./hello hello.fgpl --key mykey.key

pack 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).

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.

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.

Terminal window
fgpl_tool run ./hello --runtime ./runtime --no-sandbox --grant messages.read,ui.command

Run 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.

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.

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).

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.

test.txt
chat add 10 "Dev group" group
msg new 10 "hello there"
command hello chat=10
storage
mock https://api.example.com/ 200 "{\"ok\":true}"
timer advance 60000
quit
Terminal window
fgpl_tool run ./hello --runtime ./runtime --script test.txt --state ./.dev-state

The 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.

  1. Bump version (and storage.schema if the stored data layout changed).
  2. fgpl_tool pack ./hello hello-1.2.0.fgpl --key mykey.key.
  3. fgpl_tool verify hello-1.2.0.fgpl — check the printed permissions and publisher fingerprint.
  4. Smoke-test with fgpl_tool run hello-1.2.0.fgpl --script smoke.txt.
  5. Publish the file (a Telegram document with the .fgpl extension opens the install dialog in the app).