diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json
new file mode 100644
index 0000000..1e81c51
--- /dev/null
+++ b/.agents/plugins/marketplace.json
@@ -0,0 +1,20 @@
+{
+ "name": "agentgram",
+ "interface": {
+ "displayName": "Agentgram"
+ },
+ "plugins": [
+ {
+ "name": "agentgram",
+ "source": {
+ "source": "local",
+ "path": "./plugins/agentgram"
+ },
+ "policy": {
+ "installation": "AVAILABLE",
+ "authentication": "ON_INSTALL"
+ },
+ "category": "Productivity"
+ }
+ ]
+}
diff --git a/README.md b/README.md
index 4027029..395a719 100644
--- a/README.md
+++ b/README.md
@@ -79,6 +79,17 @@ The Codex plugin skill lives in `skills/agentgram/SKILL.md`, with the plugin
manifest at `.codex-plugin/plugin.json`. The skill tells Codex to use the local
`agentgram` CLI as the execution path.
+To install Agentgram from the public Codex marketplace file in this repository:
+
+```sh
+codex plugin marketplace add jerryfane/agentgram --ref main
+codex plugin add agentgram@agentgram
+```
+
+Start a new Codex thread after installing so the Agentgram skill is loaded.
+Use `codex plugin marketplace upgrade agentgram` before reinstalling when you
+want newer Agentgram releases.
+
When a user asks an agent to send a Telegram message, the agent should:
1. Run `agentgram doctor`, or `bin/agentgram doctor` only from this repository
diff --git a/docs/release-checklist.md b/docs/release-checklist.md
index 2edc039..36ce355 100644
--- a/docs/release-checklist.md
+++ b/docs/release-checklist.md
@@ -10,6 +10,7 @@ Use this checklist before tagging or announcing an Agentgram release.
- Run `git diff --check`.
- Run `bin/agentgram update --check` from the release checkout.
- Verify `.env.example` contains variable names only.
+- Verify `.agents/plugins/marketplace.json` points at `./plugins/agentgram`.
- Verify no Telegram tokens, chat ids, generated plugin packages, logs, caches,
or local session files are staged.
@@ -27,6 +28,18 @@ bin/agentgram doctor
`doctor` may fail until `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID` are set; it
should fail clearly without printing secret values.
+## Codex Marketplace Smoke
+
+After merging the release, verify Codex can discover the marketplace:
+
+```sh
+codex plugin marketplace add jerryfane/agentgram --ref main
+codex plugin list
+codex plugin add agentgram@agentgram
+```
+
+Start a new Codex thread after installing so updated skills are loaded.
+
## Optional Live Telegram Smoke
Only run this with explicit test credentials from the user:
diff --git a/plugins/agentgram/.codex-plugin/plugin.json b/plugins/agentgram/.codex-plugin/plugin.json
new file mode 100644
index 0000000..58d8536
--- /dev/null
+++ b/plugins/agentgram/.codex-plugin/plugin.json
@@ -0,0 +1,33 @@
+{
+ "name": "agentgram",
+ "version": "0.1.0",
+ "description": "Agent-neutral Telegram messaging helper for local coding agents.",
+ "author": {
+ "name": "Jerry Fane",
+ "url": "https://github.com/jerryfane"
+ },
+ "repository": "https://github.com/jerryfane/agentgram",
+ "license": "MIT",
+ "keywords": [
+ "telegram",
+ "agents",
+ "plugin",
+ "notifications",
+ "bot"
+ ],
+ "skills": "./skills/",
+ "interface": {
+ "displayName": "Agentgram",
+ "shortDescription": "Send Telegram messages from agent sessions.",
+ "longDescription": "Agentgram helps coding agents send explicit, user-requested Telegram messages through the local Agentgram CLI and Telegram Bot API using a bot token and chat id from the user's environment.",
+ "developerName": "Jerry Fane",
+ "category": "Productivity",
+ "capabilities": [
+ "Read",
+ "Write"
+ ],
+ "defaultPrompt": [
+ "Use Agentgram to send a Telegram message."
+ ]
+ }
+}
diff --git a/plugins/agentgram/LICENSE b/plugins/agentgram/LICENSE
new file mode 100644
index 0000000..8c535a4
--- /dev/null
+++ b/plugins/agentgram/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Jerry Fane
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/plugins/agentgram/README.md b/plugins/agentgram/README.md
new file mode 100644
index 0000000..395a719
--- /dev/null
+++ b/plugins/agentgram/README.md
@@ -0,0 +1,131 @@
+# Agentgram
+
+Agentgram is an agent-neutral Telegram messaging helper. It lets Codex and
+other local coding agents send explicit, user-requested Telegram messages
+through a Telegram bot token and chat id.
+
+Agentgram is intentionally local-first. It does not run a hosted service, and it
+does not send automatic completion notifications unless a future task explicitly
+adds that behavior.
+
+## Requirements
+
+- Python 3.12 or newer.
+- A Telegram bot token from BotFather.
+- A Telegram chat where the bot has been started or added.
+
+## Configuration
+
+Set secrets in your shell or agent runtime environment:
+
+```sh
+export TELEGRAM_BOT_TOKEN="123456:bot-token"
+export TELEGRAM_CHAT_ID="123456789"
+```
+
+Do not put real tokens in tracked files. `.env` and `.env.*` are ignored for
+local use, but environment variables are the preferred setup.
+
+For local setup templates, copy [.env.example](.env.example). It contains
+variable names only.
+
+## Usage
+
+Install from a git checkout, then put the CLI on your `PATH`:
+
+```sh
+git clone https://github.com/jerryfane/agentgram.git ~/.agentgram/agentgram
+mkdir -p ~/.local/bin
+ln -sf ~/.agentgram/agentgram/bin/agentgram ~/.local/bin/agentgram
+```
+
+Run the local CLI:
+
+```sh
+agentgram doctor
+agentgram send "deploy finished"
+agentgram send --silent --no-preview "quiet update"
+agentgram send --parse-mode HTML "deploy finished"
+```
+
+To discover a chat id, first send a message to the bot in Telegram, then run:
+
+```sh
+agentgram chat-id
+```
+
+For raw Telegram `getUpdates` output:
+
+```sh
+agentgram chat-id --raw
+```
+
+To check whether the local git checkout is current using existing local refs, or
+to update with a fast-forward-only pull:
+
+```sh
+agentgram update --check
+agentgram update
+```
+
+`agentgram update` refuses dirty worktrees, validates the checkout after pulling,
+and prints runtime-specific next steps when it can detect them. Codex plugin
+users should reinstall or refresh the plugin and start a new thread so updated
+skills are loaded.
+
+## Codex Plugin
+
+The Codex plugin skill lives in `skills/agentgram/SKILL.md`, with the plugin
+manifest at `.codex-plugin/plugin.json`. The skill tells Codex to use the local
+`agentgram` CLI as the execution path.
+
+To install Agentgram from the public Codex marketplace file in this repository:
+
+```sh
+codex plugin marketplace add jerryfane/agentgram --ref main
+codex plugin add agentgram@agentgram
+```
+
+Start a new Codex thread after installing so the Agentgram skill is loaded.
+Use `codex plugin marketplace upgrade agentgram` before reinstalling when you
+want newer Agentgram releases.
+
+When a user asks an agent to send a Telegram message, the agent should:
+
+1. Run `agentgram doctor`, or `bin/agentgram doctor` only from this repository
+ checkout.
+2. Run `agentgram send "message"`, or `bin/agentgram send "message"` only from
+ this repository checkout, if setup is valid.
+3. Avoid direct Telegram API calls unless the user explicitly asks to bypass the
+ Agentgram CLI.
+
+## Troubleshooting
+
+- Bot was not started: open Telegram, send any message to the bot, then run
+ `agentgram chat-id` again.
+- Bad token: run `agentgram doctor`; malformed tokens fail locally, and revoked
+ or wrong tokens fail the Telegram `getMe` check.
+- Missing chat id: set `TELEGRAM_CHAT_ID`, or pass `--chat-id ` for a
+ one-off send after the user provides the target chat.
+- Forbidden chat: add the bot to the target chat or start a private chat with
+ it, then retry after confirming the chat id.
+- Telegram API errors: Agentgram prints Telegram's error description without the
+ bot token. Re-run `agentgram doctor` before retrying.
+
+## Release Checks
+
+Before release, run:
+
+```sh
+python3 -m unittest discover -s tests -v
+python3 scripts/validate_manifest.py
+git diff --check
+```
+
+See [docs/release-checklist.md](docs/release-checklist.md) for the full
+checklist and fresh-clone smoke.
+
+## Status
+
+Pre-release. CLI core, Codex skill packaging, update ergonomics, release docs,
+and CI checks are implemented.
diff --git a/plugins/agentgram/bin/agentgram b/plugins/agentgram/bin/agentgram
new file mode 100755
index 0000000..4860eb1
--- /dev/null
+++ b/plugins/agentgram/bin/agentgram
@@ -0,0 +1,17 @@
+#!/usr/bin/env python3
+"""Agentgram command entry point."""
+
+from pathlib import Path
+import sys
+
+
+ROOT = Path(__file__).resolve().parents[1]
+SRC = ROOT / "src"
+if str(SRC) not in sys.path:
+ sys.path.insert(0, str(SRC))
+
+from agentgram.cli import main
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/plugins/agentgram/skills/agentgram/SKILL.md b/plugins/agentgram/skills/agentgram/SKILL.md
new file mode 100644
index 0000000..76fa5f4
--- /dev/null
+++ b/plugins/agentgram/skills/agentgram/SKILL.md
@@ -0,0 +1,72 @@
+---
+name: agentgram
+description: Send explicit, user-requested Telegram messages from an agent session through the local Agentgram command-line tool.
+---
+
+# Agentgram
+
+Agentgram is a small Telegram messaging helper for agents. Use this skill when
+the user asks to send a Telegram message, verify Telegram messaging setup, find
+a chat id, or update the local Agentgram install.
+
+Before sending messages, prefer the installed `agentgram` command. If it is not
+on `PATH` and Agentgram is installed as a Codex plugin, resolve the plugin root
+from this skill file at `/skills/agentgram/SKILL.md` and use
+`/bin/agentgram` (`../../bin/agentgram` relative to this file). Use
+`./bin/agentgram` only after verifying the current checkout is Agentgram itself,
+with `.codex-plugin/plugin.json` name `agentgram` and
+`skills/agentgram/SKILL.md` present. In any other repository, report that
+Agentgram is not installed instead of running project-local fallback scripts or
+making an ad hoc Telegram API call.
+
+## Commands
+
+```sh
+agentgram send "message text"
+agentgram chat-id
+agentgram doctor
+agentgram update
+agentgram update --check
+```
+
+## Required Setup
+
+Agentgram reads:
+
+- `TELEGRAM_BOT_TOKEN`
+- `TELEGRAM_CHAT_ID`
+
+Secrets must come from environment variables or a user-owned local config file,
+never from tracked repository files, chat output, PR bodies, logs, or generated
+plugin packages.
+
+## Send Workflow
+
+1. Resolve the safe Agentgram command as `AGENTGRAM_CMD`: `agentgram` on
+ `PATH`, `/bin/agentgram` from an installed Agentgram plugin, or
+ `./bin/agentgram` only from a verified Agentgram checkout.
+2. Run `$AGENTGRAM_CMD doctor` before sending, unless the user explicitly asks
+ for a best-effort send without preflight.
+3. If `doctor` only fails because `TELEGRAM_CHAT_ID` is missing and the user
+ provided the target chat id for this message, proceed with
+ `$AGENTGRAM_CMD send --chat-id `.
+4. If `doctor` reports missing `TELEGRAM_CHAT_ID` and the user did not provide
+ a chat id, run `$AGENTGRAM_CMD chat-id` only after the user has messaged the
+ bot or added it to the target chat.
+5. Send only the exact user-requested message with `$AGENTGRAM_CMD send`.
+6. Use `--chat-id` only when the user provided a specific override for that
+ message.
+7. Use `--parse-mode HTML` or `--parse-mode MarkdownV2` only when the user asks
+ for formatted Telegram output or the message clearly requires it.
+
+Do not send automatic status updates merely because an agent task completed.
+Agentgram sends should be explicit and user-requested.
+
+## Update Workflow
+
+Use `$AGENTGRAM_CMD update --check` for a read-only status check on git-based
+installs. Use `$AGENTGRAM_CMD update` only when the user asks to update
+Agentgram. The update command refuses dirty git checkouts, runs
+`git pull --ff-only`, validates the local CLI/plugin files, and prints any Codex
+refresh instructions it can detect. For Codex marketplace installs, update with
+`codex plugin marketplace upgrade` and then reinstall `agentgram@agentgram`.
diff --git a/plugins/agentgram/src/agentgram/__init__.py b/plugins/agentgram/src/agentgram/__init__.py
new file mode 100644
index 0000000..b09a59e
--- /dev/null
+++ b/plugins/agentgram/src/agentgram/__init__.py
@@ -0,0 +1,5 @@
+"""Agentgram Telegram messaging helpers."""
+
+__all__ = ["__version__"]
+
+__version__ = "0.1.0"
diff --git a/plugins/agentgram/src/agentgram/cli.py b/plugins/agentgram/src/agentgram/cli.py
new file mode 100644
index 0000000..16afb16
--- /dev/null
+++ b/plugins/agentgram/src/agentgram/cli.py
@@ -0,0 +1,467 @@
+"""Command-line interface for Agentgram."""
+
+from __future__ import annotations
+
+import argparse
+from html.parser import HTMLParser
+import json
+import os
+from pathlib import Path
+import subprocess
+import sys
+from typing import Any, Iterable, TextIO
+from urllib.parse import urlsplit, urlunsplit
+
+from . import __version__
+from .telegram import TelegramClient, TelegramError, looks_like_token
+
+
+MAX_TEXT_LENGTH = 4096
+TOKEN_ENV = "TELEGRAM_BOT_TOKEN"
+CHAT_ID_ENV = "TELEGRAM_CHAT_ID"
+PLUGIN_NAME = "agentgram"
+
+
+class CliError(RuntimeError):
+ """Raised for user-correctable command errors."""
+
+
+def main(argv: list[str] | None = None) -> int:
+ return run(argv, stdout=sys.stdout, stderr=sys.stderr, environ=os.environ)
+
+
+def run(
+ argv: list[str] | None = None,
+ *,
+ stdout: TextIO,
+ stderr: TextIO,
+ environ: dict[str, str],
+) -> int:
+ parser = build_parser()
+ args = parser.parse_args(argv)
+ try:
+ result = args.func(args, stdout=stdout, environ=environ)
+ except CliError as exc:
+ print(f"agentgram: {exc}", file=stderr)
+ return 2
+ except TelegramError as exc:
+ print(f"agentgram: {exc}", file=stderr)
+ return 1
+ return result
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ prog="agentgram",
+ description="Send explicit Telegram messages from local agent sessions.",
+ )
+ parser.add_argument("--version", action="version", version=f"agentgram {__version__}")
+ subcommands = parser.add_subparsers(dest="command", required=True)
+
+ send = subcommands.add_parser("send", help="send a Telegram text message")
+ send.add_argument("--chat-id", help=f"override {CHAT_ID_ENV}")
+ send.add_argument("--parse-mode", choices=("HTML", "MarkdownV2"), help="Telegram parse mode")
+ send.add_argument("--silent", action="store_true", help="send without notification sound")
+ send.add_argument("--no-preview", action="store_true", help="disable link previews")
+ send.add_argument("text", nargs="+", help="message text")
+ send.set_defaults(func=cmd_send)
+
+ chat_id = subcommands.add_parser("chat-id", help="show candidate chat ids from recent updates")
+ chat_id.add_argument("--raw", action="store_true", help="print raw getUpdates JSON")
+ chat_id.set_defaults(func=cmd_chat_id)
+
+ doctor = subcommands.add_parser("doctor", help="check Agentgram and Telegram configuration")
+ doctor.add_argument("--json", action="store_true", dest="json_output", help="print JSON")
+ doctor.set_defaults(func=cmd_doctor)
+
+ update = subcommands.add_parser("update", help="check or update a git-based Agentgram checkout")
+ update.add_argument("--check", action="store_true", help="only check update status")
+ update.add_argument("--repo", default=str(repo_root()), help="Agentgram repository path")
+ update.set_defaults(func=cmd_update)
+ return parser
+
+
+def cmd_send(args: argparse.Namespace, *, stdout: TextIO, environ: dict[str, str]) -> int:
+ token = require_env(environ, TOKEN_ENV)
+ chat_id = args.chat_id or require_env(environ, CHAT_ID_ENV)
+ text = normalize_text(args.text)
+ payload = build_send_payload(
+ chat_id=chat_id,
+ text=text,
+ parse_mode=args.parse_mode,
+ silent=args.silent,
+ no_preview=args.no_preview,
+ )
+ message = TelegramClient(token).send_message(payload)
+ message_id = message.get("message_id")
+ if message_id is None:
+ print("sent", file=stdout)
+ else:
+ print(f"sent message_id={message_id}", file=stdout)
+ return 0
+
+
+def cmd_chat_id(args: argparse.Namespace, *, stdout: TextIO, environ: dict[str, str]) -> int:
+ token = require_env(environ, TOKEN_ENV)
+ updates = TelegramClient(token).get_updates()
+ if args.raw:
+ print(json.dumps(updates, indent=2, sort_keys=True), file=stdout)
+ return 0
+
+ candidates = extract_chat_candidates(updates)
+ if not candidates:
+ print("No chat ids found. Send a message to the bot, then run this command again.", file=stdout)
+ return 0
+ for candidate in candidates:
+ title = candidate.get("title") or candidate.get("username") or candidate.get("name") or "(untitled)"
+ print(f"{candidate['id']}\t{candidate['type']}\t{title}", file=stdout)
+ return 0
+
+
+def cmd_doctor(args: argparse.Namespace, *, stdout: TextIO, environ: dict[str, str]) -> int:
+ checks: list[dict[str, Any]] = []
+ token = environ.get(TOKEN_ENV, "").strip()
+ chat_id = environ.get(CHAT_ID_ENV, "").strip()
+ root = repo_root()
+ checks.append(check("bot_token_env", bool(token), f"{TOKEN_ENV} is {'set' if token else 'missing'}"))
+ checks.append(
+ check(
+ "bot_token_shape",
+ bool(token and looks_like_token(token)),
+ "token shape looks valid" if token and looks_like_token(token) else "token shape is invalid or unknown",
+ required=False,
+ )
+ )
+ checks.append(check("chat_id_env", bool(chat_id), f"{CHAT_ID_ENV} is {'set' if chat_id else 'missing'}"))
+ checks.append(
+ check(
+ "plugin_manifest",
+ (root / ".codex-plugin" / "plugin.json").is_file(),
+ ".codex-plugin/plugin.json present",
+ required=False,
+ )
+ )
+ checks.append(
+ check(
+ "skill_file",
+ (root / "skills" / "agentgram" / "SKILL.md").is_file(),
+ "skills/agentgram/SKILL.md present",
+ required=False,
+ )
+ )
+ origin = git_origin_url(root)
+ safe_origin = redact_url_userinfo(origin)
+ checks.append(
+ check(
+ "git_origin",
+ bool(origin),
+ f"origin remote is {safe_origin}" if origin else "origin remote is missing or unavailable",
+ required=False,
+ )
+ )
+
+ if token:
+ try:
+ bot = TelegramClient(token).get_me()
+ username = bot.get("username") or bot.get("first_name") or "bot"
+ checks.append(check("telegram_get_me", True, f"authenticated as {username}"))
+ except TelegramError as exc:
+ checks.append(check("telegram_get_me", False, str(exc)))
+
+ ok = all(item["ok"] for item in checks if item["required"])
+ if args.json_output:
+ print(json.dumps({"ok": ok, "checks": checks}, indent=2, sort_keys=True), file=stdout)
+ else:
+ for item in checks:
+ status = "ok" if item["ok"] else "fail"
+ required = "required" if item["required"] else "optional"
+ print(f"{status}\t{item['name']}\t{required}\t{item['detail']}", file=stdout)
+ return 0 if ok else 1
+
+
+def cmd_update(args: argparse.Namespace, *, stdout: TextIO, environ: dict[str, str]) -> int:
+ del environ
+ repo = Path(args.repo).expanduser().resolve()
+ if not (repo / ".git").exists():
+ raise CliError(f"{repo} is not a git checkout")
+
+ if args.check:
+ status = git_update_status(repo)
+ print(status, file=stdout)
+ return 0
+
+ ensure_clean_worktree(repo)
+ validate_checkout(repo)
+ print(git_update_status(repo), file=stdout)
+ pull_result = run_git(repo, "pull", "--ff-only")
+ if pull_result:
+ print(pull_result, file=stdout)
+ validate_checkout(repo)
+ print("validation ok", file=stdout)
+ for line in update_next_steps(repo):
+ print(line, file=stdout)
+ return 0
+
+
+def build_send_payload(
+ *,
+ chat_id: str,
+ text: str,
+ parse_mode: str | None,
+ silent: bool,
+ no_preview: bool,
+) -> dict[str, Any]:
+ if not str(chat_id).strip():
+ raise CliError("chat id is required")
+ validate_text(text, parse_mode=parse_mode)
+ payload: dict[str, Any] = {"chat_id": chat_id, "text": text}
+ if parse_mode:
+ payload["parse_mode"] = parse_mode
+ if silent:
+ payload["disable_notification"] = True
+ if no_preview:
+ payload["link_preview_options"] = {"is_disabled": True}
+ return payload
+
+
+def normalize_text(parts: Iterable[str]) -> str:
+ text = " ".join(parts).strip()
+ validate_text(text, parse_mode=None, enforce_max=False)
+ return text
+
+
+def validate_text(text: str, *, parse_mode: str | None = None, enforce_max: bool = True) -> None:
+ if not text:
+ raise CliError("message text is required")
+ length = telegram_text_length(text, parse_mode)
+ if enforce_max and length > MAX_TEXT_LENGTH:
+ raise CliError(f"message text is too long: {length} characters; maximum is {MAX_TEXT_LENGTH}")
+
+
+def telegram_text_length(text: str, parse_mode: str | None) -> int:
+ if parse_mode == "HTML":
+ return len(html_visible_text(text))
+ if parse_mode == "MarkdownV2":
+ return len(markdown_v2_visible_text(text))
+ return len(text)
+
+
+class _VisibleHTMLParser(HTMLParser):
+ def __init__(self) -> None:
+ super().__init__(convert_charrefs=True)
+ self.parts: list[str] = []
+
+ def handle_data(self, data: str) -> None:
+ self.parts.append(data)
+
+
+def html_visible_text(text: str) -> str:
+ parser = _VisibleHTMLParser()
+ parser.feed(text)
+ parser.close()
+ return "".join(parser.parts)
+
+
+def markdown_v2_visible_text(text: str) -> str:
+ visible: list[str] = []
+ i = 0
+ formatting = set("_*[]()~`>#+-=|{}.!")
+ while i < len(text):
+ if text.startswith("```", i):
+ i += 3
+ closing = text.find("```", i)
+ if closing == -1:
+ visible.append(text[i:])
+ break
+ visible.append(text[i:closing])
+ i = closing + 3
+ continue
+ char = text[i]
+ if char == "[":
+ label_end = text.find("](", i + 1)
+ if label_end != -1:
+ destination_end = text.find(")", label_end + 2)
+ if destination_end != -1:
+ visible.append(markdown_v2_visible_text(text[i + 1 : label_end]))
+ i = destination_end + 1
+ continue
+ if char == "`":
+ closing = text.find("`", i + 1)
+ if closing == -1:
+ i += 1
+ continue
+ visible.append(text[i + 1 : closing])
+ i = closing + 1
+ continue
+ if char == "\\" and i + 1 < len(text):
+ visible.append(text[i + 1])
+ i += 2
+ continue
+ if char in formatting:
+ i += 1
+ continue
+ visible.append(char)
+ i += 1
+ return "".join(visible)
+
+
+def require_env(environ: dict[str, str], name: str) -> str:
+ value = environ.get(name, "").strip()
+ if not value:
+ raise CliError(f"{name} is required")
+ return value
+
+
+def extract_chat_candidates(updates: list[dict[str, Any]]) -> list[dict[str, str]]:
+ seen: set[str] = set()
+ candidates: list[dict[str, str]] = []
+ for update in updates:
+ for key in ("message", "edited_message", "channel_post", "edited_channel_post", "business_message"):
+ message = update.get(key)
+ if not isinstance(message, dict):
+ continue
+ chat = message.get("chat")
+ if not isinstance(chat, dict) or "id" not in chat:
+ continue
+ chat_id = str(chat["id"])
+ if chat_id in seen:
+ continue
+ seen.add(chat_id)
+ name = chat.get("title") or " ".join(
+ part for part in (chat.get("first_name"), chat.get("last_name")) if part
+ )
+ candidates.append(
+ {
+ "id": chat_id,
+ "type": str(chat.get("type") or "unknown"),
+ "title": str(chat.get("title") or ""),
+ "username": str(chat.get("username") or ""),
+ "name": str(name or ""),
+ }
+ )
+ return candidates
+
+
+def check(name: str, ok: bool, detail: str, *, required: bool = True) -> dict[str, Any]:
+ return {"name": name, "ok": ok, "detail": detail, "required": required}
+
+
+def git_update_status(repo: Path) -> str:
+ branch = run_git(repo, "rev-parse", "--abbrev-ref", "HEAD", allow_fail=True) or "unknown"
+ upstream = run_git(repo, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}", allow_fail=True)
+ if not upstream:
+ return f"{branch}: unknown update state; no upstream configured"
+ left_right = run_git(repo, "rev-list", "--left-right", "--count", f"{branch}...{upstream}", allow_fail=True)
+ if not left_right:
+ return f"{branch}: unknown update state relative to {upstream}"
+ try:
+ ahead, behind = [int(part) for part in left_right.split()]
+ except ValueError:
+ return f"{branch}: unknown update state relative to {upstream}"
+ if ahead == 0 and behind == 0:
+ return f"{branch}: up to date with local ref {upstream}"
+ return f"{branch}: ahead {ahead}, behind {behind} relative to local ref {upstream}"
+
+
+def ensure_clean_worktree(repo: Path) -> None:
+ status = run_git(repo, "status", "--porcelain")
+ if status:
+ raise CliError("refusing to update because the git worktree has uncommitted changes")
+
+
+def validate_checkout(repo: Path) -> None:
+ required_files = [
+ repo / "bin" / "agentgram",
+ repo / ".codex-plugin" / "plugin.json",
+ repo / "skills" / PLUGIN_NAME / "SKILL.md",
+ repo / "src" / PLUGIN_NAME / "cli.py",
+ ]
+ missing = [str(path.relative_to(repo)) for path in required_files if not path.is_file()]
+ if missing:
+ raise CliError(f"checkout validation failed; missing {', '.join(missing)}")
+
+ try:
+ manifest = json.loads((repo / ".codex-plugin" / "plugin.json").read_text(encoding="utf-8"))
+ except json.JSONDecodeError as exc:
+ raise CliError(f"checkout validation failed; plugin manifest is invalid JSON: {exc}") from exc
+ if manifest.get("name") != PLUGIN_NAME:
+ raise CliError(f"checkout validation failed; plugin manifest name is not {PLUGIN_NAME}")
+ if manifest.get("skills") != "./skills/":
+ raise CliError("checkout validation failed; plugin manifest skills path is not ./skills/")
+
+
+def update_next_steps(repo: Path) -> list[str]:
+ steps = [
+ "Next steps:",
+ f"- CLI users: keep using {repo / 'bin' / 'agentgram'} or refresh your PATH/symlink if needed.",
+ ]
+ codex_entry = detected_codex_agentgram_entry()
+ if codex_entry:
+ steps.extend(
+ [
+ f"- Codex plugin detected: refresh with `codex plugin add {codex_entry}`.",
+ "- Start a new Codex thread after reinstall so updated skills are loaded.",
+ ]
+ )
+ else:
+ steps.append("- Codex users: reinstall or refresh the Agentgram plugin from the marketplace where you added it.")
+ return steps
+
+
+def detected_codex_agentgram_entry() -> str | None:
+ try:
+ proc = subprocess.run(
+ ["codex", "plugin", "list"],
+ text=True,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ check=False,
+ )
+ except OSError:
+ return None
+ if proc.returncode != 0:
+ return None
+ for line in proc.stdout.splitlines():
+ fields = line.split()
+ if not fields:
+ continue
+ entry = fields[0]
+ status = fields[1] if len(fields) > 1 else ""
+ if entry.startswith(f"{PLUGIN_NAME}@") and status.startswith("installed"):
+ return entry
+ return None
+
+
+def git_origin_url(repo: Path) -> str:
+ return run_git(repo, "remote", "get-url", "origin", allow_fail=True)
+
+
+def redact_url_userinfo(url: str) -> str:
+ try:
+ parsed = urlsplit(url)
+ except ValueError:
+ return url
+ if parsed.scheme and "@" in parsed.netloc:
+ host = parsed.netloc.rsplit("@", 1)[1]
+ return urlunsplit((parsed.scheme, host, parsed.path, parsed.query, parsed.fragment))
+ return url
+
+
+def run_git(repo: Path, *args: str, allow_fail: bool = False) -> str:
+ proc = subprocess.run(
+ ["git", *args],
+ cwd=repo,
+ text=True,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ check=False,
+ )
+ if proc.returncode != 0:
+ if allow_fail:
+ return ""
+ raise CliError(proc.stderr.strip() or f"git {' '.join(args)} failed")
+ return proc.stdout.strip()
+
+
+def repo_root() -> Path:
+ return Path(__file__).resolve().parents[2]
diff --git a/plugins/agentgram/src/agentgram/telegram.py b/plugins/agentgram/src/agentgram/telegram.py
new file mode 100644
index 0000000..aace7ee
--- /dev/null
+++ b/plugins/agentgram/src/agentgram/telegram.py
@@ -0,0 +1,89 @@
+"""Small Telegram Bot API client built on the Python standard library."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+import json
+import re
+from typing import Any
+from urllib import error, request
+
+
+API_ROOT = "https://api.telegram.org"
+TOKEN_RE = re.compile(r"^[0-9]+:[A-Za-z0-9_-]{20,}$")
+
+
+class TelegramError(RuntimeError):
+ """Raised when Telegram returns an error response or cannot be reached."""
+
+
+@dataclass(frozen=True)
+class TelegramClient:
+ token: str
+ api_root: str = API_ROOT
+ timeout: float = 15.0
+
+ def request(self, method: str, payload: dict[str, Any] | None = None) -> Any:
+ token = self.token.strip()
+ if not looks_like_token(token):
+ raise TelegramError("Telegram bot token shape is invalid")
+ payload = payload or {}
+ body = json.dumps(payload).encode("utf-8")
+ url = f"{self.api_root}/bot{token}/{method}"
+ req = request.Request(
+ url,
+ data=body,
+ headers={"Content-Type": "application/json"},
+ method="POST",
+ )
+ try:
+ with request.urlopen(req, timeout=self.timeout) as response:
+ raw = response.read().decode("utf-8")
+ except error.HTTPError as exc:
+ raw_error = exc.read().decode("utf-8", errors="replace")
+ raise TelegramError(redact_token(_telegram_error_message(raw_error), token)) from exc
+ except error.URLError as exc:
+ raise TelegramError(redact_token(f"Telegram request failed: {exc.reason}", token)) from exc
+
+ try:
+ decoded = json.loads(raw)
+ except json.JSONDecodeError as exc:
+ raise TelegramError("Telegram returned invalid JSON") from exc
+
+ if not decoded.get("ok"):
+ description = decoded.get("description") or "Telegram API request failed"
+ raise TelegramError(redact_token(str(description), token))
+ return decoded.get("result")
+
+ def get_me(self) -> dict[str, Any]:
+ return self.request("getMe", {})
+
+ def get_updates(self, limit: int = 20) -> list[dict[str, Any]]:
+ result = self.request("getUpdates", {"limit": limit, "timeout": 0})
+ if not isinstance(result, list):
+ raise TelegramError("Telegram getUpdates returned an unexpected result")
+ return result
+
+ def send_message(self, payload: dict[str, Any]) -> dict[str, Any]:
+ result = self.request("sendMessage", payload)
+ if not isinstance(result, dict):
+ raise TelegramError("Telegram sendMessage returned an unexpected result")
+ return result
+
+
+def looks_like_token(value: str) -> bool:
+ return bool(TOKEN_RE.match(value.strip()))
+
+
+def redact_token(message: str, token: str | None) -> str:
+ if not token:
+ return message
+ return message.replace(token, "")
+
+
+def _telegram_error_message(raw: str) -> str:
+ try:
+ decoded = json.loads(raw)
+ except json.JSONDecodeError:
+ return raw or "Telegram API request failed"
+ return str(decoded.get("description") or decoded.get("error_code") or "Telegram API request failed")
diff --git a/scripts/validate_manifest.py b/scripts/validate_manifest.py
index b9420d7..be12ec9 100644
--- a/scripts/validate_manifest.py
+++ b/scripts/validate_manifest.py
@@ -14,6 +14,7 @@
def main() -> int:
errors: list[str] = []
manifest_path = ROOT / ".codex-plugin" / "plugin.json"
+ manifest: dict[str, object] = {}
if not manifest_path.is_file():
errors.append("missing .codex-plugin/plugin.json")
else:
@@ -30,6 +31,8 @@ def main() -> int:
errors.append("plugin version is required")
if not manifest.get("interface", {}).get("defaultPrompt"):
errors.append("plugin interface.defaultPrompt is required")
+ if manifest.get("version") != "0.1.0":
+ errors.append("plugin version must match the v0.1.0 release")
skill_files = sorted((ROOT / "skills").glob("**/SKILL.md"))
if skill_files != [ROOT / "skills" / "agentgram" / "SKILL.md"]:
@@ -52,6 +55,65 @@ def main() -> int:
if "TELEGRAM_BOT_TOKEN" not in text or "TELEGRAM_CHAT_ID" not in text:
errors.append(f"{path.relative_to(ROOT)} must document Telegram env vars")
+ marketplace_path = ROOT / ".agents" / "plugins" / "marketplace.json"
+ if not marketplace_path.is_file():
+ errors.append("missing .agents/plugins/marketplace.json")
+ else:
+ try:
+ marketplace = json.loads(marketplace_path.read_text(encoding="utf-8"))
+ except json.JSONDecodeError as exc:
+ errors.append(f"invalid .agents/plugins/marketplace.json: {exc}")
+ else:
+ if marketplace.get("name") != "agentgram":
+ errors.append("marketplace name must be agentgram")
+ plugins = marketplace.get("plugins")
+ if not isinstance(plugins, list) or len(plugins) != 1:
+ errors.append("marketplace must expose exactly one plugin")
+ else:
+ plugin = plugins[0]
+ if not isinstance(plugin, dict):
+ errors.append("marketplace plugin entry must be an object")
+ else:
+ if plugin.get("name") != "agentgram":
+ errors.append("marketplace plugin name must be agentgram")
+ source = plugin.get("source")
+ if not isinstance(source, dict):
+ errors.append("marketplace source must be an object")
+ else:
+ if source.get("source") != "local":
+ errors.append("marketplace source must be local")
+ if source.get("path") != "./plugins/agentgram":
+ errors.append("marketplace source path must point at ./plugins/agentgram")
+ policy = plugin.get("policy")
+ if not isinstance(policy, dict):
+ errors.append("marketplace policy must be present")
+ else:
+ if policy.get("installation") != "AVAILABLE":
+ errors.append("marketplace installation policy must be AVAILABLE")
+ if policy.get("authentication") != "ON_INSTALL":
+ errors.append("marketplace authentication policy must be ON_INSTALL")
+ if plugin.get("category") != "Productivity":
+ errors.append("marketplace category must be Productivity")
+
+ packaged_root = ROOT / "plugins" / "agentgram"
+ packaged_files = [
+ ".codex-plugin/plugin.json",
+ "LICENSE",
+ "README.md",
+ "bin/agentgram",
+ "src/agentgram/__init__.py",
+ "src/agentgram/cli.py",
+ "src/agentgram/telegram.py",
+ "skills/agentgram/SKILL.md",
+ ]
+ for relative in packaged_files:
+ root_file = ROOT / relative
+ packaged_file = packaged_root / relative
+ if not packaged_file.is_file():
+ errors.append(f"missing packaged plugin file plugins/agentgram/{relative}")
+ elif root_file.read_text(encoding="utf-8") != packaged_file.read_text(encoding="utf-8"):
+ errors.append(f"packaged plugin file plugins/agentgram/{relative} is out of sync")
+
if errors:
for error in errors:
print(f"error: {error}", file=sys.stderr)
diff --git a/skills/agentgram/SKILL.md b/skills/agentgram/SKILL.md
index 138163e..76fa5f4 100644
--- a/skills/agentgram/SKILL.md
+++ b/skills/agentgram/SKILL.md
@@ -10,8 +10,11 @@ the user asks to send a Telegram message, verify Telegram messaging setup, find
a chat id, or update the local Agentgram install.
Before sending messages, prefer the installed `agentgram` command. If it is not
-on `PATH`, use `./bin/agentgram` only after verifying the current checkout is
-Agentgram itself, with `.codex-plugin/plugin.json` name `agentgram` and
+on `PATH` and Agentgram is installed as a Codex plugin, resolve the plugin root
+from this skill file at `/skills/agentgram/SKILL.md` and use
+`/bin/agentgram` (`../../bin/agentgram` relative to this file). Use
+`./bin/agentgram` only after verifying the current checkout is Agentgram itself,
+with `.codex-plugin/plugin.json` name `agentgram` and
`skills/agentgram/SKILL.md` present. In any other repository, report that
Agentgram is not installed instead of running project-local fallback scripts or
making an ad hoc Telegram API call.
@@ -40,7 +43,8 @@ plugin packages.
## Send Workflow
1. Resolve the safe Agentgram command as `AGENTGRAM_CMD`: `agentgram` on
- `PATH`, or `./bin/agentgram` only from a verified Agentgram checkout.
+ `PATH`, `/bin/agentgram` from an installed Agentgram plugin, or
+ `./bin/agentgram` only from a verified Agentgram checkout.
2. Run `$AGENTGRAM_CMD doctor` before sending, unless the user explicitly asks
for a best-effort send without preflight.
3. If `doctor` only fails because `TELEGRAM_CHAT_ID` is missing and the user
@@ -60,7 +64,9 @@ Agentgram sends should be explicit and user-requested.
## Update Workflow
-Use `$AGENTGRAM_CMD update --check` for a read-only status check. Use
-`$AGENTGRAM_CMD update` only when the user asks to update Agentgram. The update
-command refuses dirty git checkouts, runs `git pull --ff-only`, validates the
-local CLI/plugin files, and prints any Codex refresh instructions it can detect.
+Use `$AGENTGRAM_CMD update --check` for a read-only status check on git-based
+installs. Use `$AGENTGRAM_CMD update` only when the user asks to update
+Agentgram. The update command refuses dirty git checkouts, runs
+`git pull --ff-only`, validates the local CLI/plugin files, and prints any Codex
+refresh instructions it can detect. For Codex marketplace installs, update with
+`codex plugin marketplace upgrade` and then reinstall `agentgram@agentgram`.