Нажал кнопку, сказал задачу, забыл: как я связал PLAUD, Raspberry Pi, Codex и Microsoft To Do


Телефон для быстрых заметок подходит не всегда. Нужно достать его, разблокировать, открыть приложение, что-то напечатать. Особенно неудобно, когда одновременно занят другой задачей и не хочется полностью переключать контекст.
Поэтому я начал смотреть, что вообще есть на рынке для создания голосовых заметок, которые автоматически превращаются в текст. Главными критериями были качественное распознавание русской речи и возможность носить устройство на запястье. Купил PLAUD и довольно быстро понял, что сам по себе гаджет решает только половину задачи.
Я могу нажать кнопку и сказать:
Работа. В понедельник в 15:00 проверить резервные копии Exchange, напомнить за час.
PLAUD нормально сохранит запись и создаст текст.
Но дальше мне всё равно нужно открыть приложение, найти нужную запись, прочитать её и руками создать задачу в Microsoft To Do. То есть вместо одного действия я всё равно получаю несколько.
Мне хотелось другого:
нажал кнопку → сказал мысль → забыл → задача сама появилась в To Do с категорией, сроком и напоминанием.
В итоге я собрал небольшую систему на Raspberry Pi Zero 2 W.
Что вы найдёте в статье
На примере своей системы покажу:
Как связать PLAUD, Codex и Microsoft To Do: надиктовал мысль — получил готовую задачу.
Как сократить расход токенов, передав рутинные операции Python и оставив модели только разбор текста.
Как извлекать из разговорной речи сроки, напоминания и категории задач.
Как запустить всё на Raspberry Pi, чтобы автоматизация работала круглосуточно.
Как хранить состояние обработки в SQLite, повторять попытки после сбоев и избегать дубликатов.
Какие сложности возникли с авторизацией сервисов и как я их решил.
Как использовать накопленные задачи для недельных отчётов и коротких сводок на дейли.
Что получилось
Сейчас сценарий выглядит именно так, как мне изначально хотелось.
Например, я могу сказать:
Здоровье. В пятницу сходить к стоматологу.
Через некоторое время в моем таскере Microsoft To Do появляется:
Здоровье: Сходить к стоматологу
с категорией
Здоровье;соответствующей датой;
исходной транскрипцией PLAUD в заметке;
идентификатором исходной записи.
Или:
Работа. В понедельник в 15:00 проверить резервные копии Exchange, напомнить за час.
Система создаст задачу с:
Название:
Работа: Проверить резервные копии Exchange
Due:
понедельник, 15:00
Reminder:
14:00
Category:
РаботаПри этом Raspberry работает полностью автономно. Никакой открытой SSH-сессии или постоянно работающего Codex не требуется.
Архитектура
Сейчас схема выглядит примерно так:

А всё это запускается systemd timer каждые 15 минут.
Почему вообще Raspberry Pi
Хотелось, чтобы автоматизация:
не зависела от моего рабочего компьютера;
не требовала открытого браузера;
переживала перезагрузки;
работала 24/7;
почти ничего не потребляла.
Для этой задачи мощности Raspberry Pi Zero 2 W хватает с большим запасом.
На нём сейчас работают:
Python
Node.js
PLAUD MCP
Codex CLI
SQLite
systemd
Microsoft Graph/MSALСама модель, разумеется, на Raspberry не запускается — Codex обращается к облачной модели.
Первая версия оказалась слишком дорогой
Первоначально я сделал самый очевидный вариант.
Codex сам получал доступ к PLAUD через подключенный MCP и выполнял запрос вроде:
List my recent Plaud recordingsРаботало прекрасно. Я отправлял запрос — агент получал записи и возвращал результат.
Но потом я посмотрел на расход токенов.
Один очень простой запрос:
list my recent Plaud recordingsсъел:
16 901 tokensХотя конечный результат состоял буквально из нескольких названий записей.
Причина довольно очевидная: агенту передавался не только мой prompt.
В контекст попадали:
системные инструкции Codex;
описание доступных инструментов;
MCP tools;
вызов
list_files;ответ PLAUD;
агентный контекст;
финальный ответ.
Для ручной работы это нормально.
Но запускать такую конструкцию каждые 5 минут — уже сомнительная идея.
288 запусков в сутки только для того, чтобы узнать, появилась ли новая запись.
Главное изменение архитектуры
После этого я разделил систему на две части.
Все механические операции выполняет обычный Python:
получить список PLAUD записей
↓
сравнить ID с SQLite
↓
получить только новые транскрипции
↓
сохранить ихА LLM используется только там, где действительно нужен смысл текста.
То есть теперь:
Python → PLAUD
Python → SQLite
↓ только новые тексты
CodexЕсли новых записей нет:
Codex вообще не запускается.Это оказалось важным архитектурным решением проекта.
Экономия оказалась существенной
Первый тест:
Codex → PLAUD MCP → list_filesдал:
16 901 tokensПосле переноса получения PLAUD-данных в Python тот же смысловой тест на двух заметках:
2 712 tokensПотом я попробовал заставить Codex отдавать результат через довольно подробную JSON Schema.
Получил:
12 146 tokensSchema была убрана.
Финальный облегчённый классификатор обычно показывает примерно:
2 700–3 500 tokensна небольшой batch заметок.
Получилось примерно так:
Версия | Что делает LLM | Расход в тесте |
|---|---|---|
Первая | сама ходит в PLAUD через MCP | 16 901 |
Structured output | классификация + большая schema | 12 146 |
Финальная | получает только текст | ~2 700–3 500 |
При этом точный расход квоты Codex, конечно, не обязан линейно соответствовать tokens used, но разница в объёме контекста хорошо видна.
Получение записей из PLAUD без Codex
PLAUD MCP предоставляет несколько полезных методов:
list_files
get_file
get_note
get_transcriptСначала Python вызывает:
list_filesи получает примерно:
{
"id": "of_...",
"name": "2026-09-16 13:44:54",
"created_at": "2026-09-16T10:44:54",
"duration": 8000
}Главное поле здесь — id.
Оно отлично подходит для дедупликации.
Если такого ID в SQLite ещё нет, Raspberry получает транскрипцию:
get_transcript(
file_id,
block="transaction_polish"
)Я предпочитаю transaction_polish, потому что PLAUD уже немного очищает распознанную речь.
Если polished-вариант недоступен, можно использовать обычный:
transactionСамо аудио Raspberry вообще не скачивает.
SQLite как небольшая state machine
Для такой автоматизации оказалось очень удобно не пытаться делать всё одним огромным скриптом.
Каждая запись имеет состояние.
Основная цепочка:
waiting_transcript
↓
pending_ai
↓
ready_todo
↓
syncedЕсть также конечные состояния:
done
note
question
ignoredНапример, запись:
Работа. Создать тестовую задачу для интеграции PLAUD и Codex.
получает:
pending_aiпосле классификации:
ready_todoпосле успешного Microsoft Graph POST:
syncedА фраза:
Отчет. Заказал кабели, всё уже приехало.
может быть классифицирована как:
doneи в Microsoft To Do попадет как уже закрытая - в будущем, это пригодится в отчетах о проделанной работе.
Я сохраняю ещё и временную трассировку
В базе лежат:
created_at
first_seen_at
transcript_received_at
classified_at
todo_created_atЭто позволяет посмотреть весь pipeline:
PLAUD создал запись
↓
Raspberry увидел
↓
получил transcript
↓
Luna классифицировала
↓
Microsoft To Do создал задачуНапример, можно потом посчитать реальную задержку:
voice → To Do = 4 мин 18 секИли быстро понять, где именно возникла проблема.
Что делает LLM
На этом этапе задача модели очень узкая.
Она получает что-то вроде:
[
{
"id": "of_123",
"text": "Работа. Завтра проверить резервные копии Exchange"
}
]и должна определить:
type
title
due_date
due_time
reminder_at
categoryПример результата:
{
"id": "of_123",
"type": "task",
"title": "Проверить резервные копии Exchange",
"due_date": "2026-09-18",
"due_time": null,
"reminder_at": null,
"category": "work"
}Использую GPT-5.6 Luna через Codex CLI.
Для подобной классификации более тяжёлая модель мне просто не нужна.
Естественные даты
Отдельно хотелось иметь возможность говорить нормально, а не диктовать ISO 8601.
Например:
завтра
послезавтра
в понедельник
в пятницу
на выходныхЕсли сегодня среда и я говорю:
В понедельник проверить бэкапы.
модель возвращает ближайший будущий понедельник:
2026-09-21То же самое со временем:
В понедельник в 15:00 проверить бэкапы.
получается:
{
"due_date": "2026-09-21",
"due_time": "15:00"
}Напоминания
Можно сказать:
В понедельник в 15:00 проверить резервные копии, напомнить за час.
Результат:
{
"due_date": "2026-09-21",
"due_time": "15:00",
"reminder_at": "2026-09-21T14:00"
}В Microsoft Graph уходят уже нормальные:
dueDateTime
reminderDateTime
isReminderOnВ результате To Do показывает срок и обычное push-напоминание.
Самая неожиданная часть — категории
Я быстро привык начинать заметки ключевым словом:
Работа.
Хобби.
Личное.
Здоровье.И стал использовать первое слово как категорию Microsoft To Do.
Например:
Здоровье. Сходить к стоматологу.
становится:
Здоровье: Сходить к стоматологуи задача получает настоящую Microsoft category:
ЗдоровьеВ Microsoft Graph это выглядит примерно так:
"categories": [
"Здоровье"
]А потом вмешалось распознавание речи
Конечно, STT не всегда слышит слово одинаково.
Я получил варианты вроде:
Здоровье
Здоровью
Здаровье
ЗдароваЕсли создавать category из первого слова, через некоторое время в Microsoft окажется:
Здоровье
Здоровью
Здаровье
Здаровачто довольно быстро превратится в помойку.
Поэтому добавил нормализацию.
Важно: она применяется только к первому слову.
Остальную часть задачи fuzzy matching никогда не трогает.
Exact → alias → fuzzy
Алгоритм такой:
первое слово
↓
exact match
↓
alias
↓
fuzzy match
↓
новая категорияНапример:
Здоровье
→ exact
→ ЗдоровьеЗдоровью
→ alias
→ ЗдоровьеЗдаровье
→ alias/fuzzy
→ ЗдоровьеХобий
→ fuzzy
→ ХоббиКак отличить новую категорию от обычного первого слова
Есть ещё одна проблема: первое слово в голосовой заметке далеко не всегда означает категорию.
Допустим, запись:
Сходить к врачу.
Первое слово — Сходить.
Очевидно, создавать category Сходить не нужно.
Поэтому правило сейчас такое.
Если есть явный разделитель, точка или запятая - тоесть когда ты сказал первое слово и сделал небольшую паузу, получается вот такая транскрипция:
Автомобиль. Купить антифриз.
здесь уже, слово Автомобиль - будет новой категорией, которая создастся автоматически.
Результат в To Do:
Автомобиль: Купить антифризЕсли разделителя нет:
Автомобиль купить антифриз.
то система сначала ищет Автомобиль среди существующих категорий.
Если такой категории нет — задача всё равно создаётся, но без категории.
То есть:
Сходить к врачу
→ обычная task
→ category=NoneЭто сильно снижает количество ложных категорий.
Microsoft To Do и странности клиентов
Тут обнаружилась любопытная особенность.
Через Microsoft Graph я вижу:
TITLE: Сходить к стоматологу
CATEGORIES: ["Здоровье"]То есть category точно записана.
В веб-версии Microsoft To Do она прекрасно отображается:
● ЗдоровьеА вот Windows-приложение категории просто не показывает.
Поэтому я решил дублировать информацию ещё и в названии:
Здоровье: Сходить к стоматологуЭто не замена настоящей category.
Задача одновременно имеет:
Title:
Здоровье: Сходить к стоматологу
Category:
ЗдоровьеТакой подход оказался удобнее: если клиент Microsoft To Do поддерживает категории — я получаю нормальную категоризацию. Если нет — нужная информация всё равно остаётся прямо в названии задачи.
Microsoft Graph
Для интеграции используется обычный Microsoft Graph с delegated permissions.

Основное разрешение:
Tasks.ReadWriteДля управления Outlook master categories понадобилось:
MailboxSettings.ReadWriteАвторизация сделана через Device Code Flow.
На Raspberry хранится MSAL token cache, поэтому повторно вводить пароль при каждом запуске не нужно.
Client secret для такого сценария не требуется.
Отказоустойчивость
Я специально не стал делать:
получили PLAUD
↓
сразу вызвали AI
↓
сразу POST MicrosoftПроблема такого подхода в том, что при любой ошибке становится непонятно, на каком этапе всё остановилось.
Например, запись уже обработали через AI, но Microsoft Graph вернул ошибку. Или задача создалась, но система упала до того, как успела сохранить результат.
Поэтому между всеми этапами появилась SQLite. Это позволяет спокойно переживать:
отсутствие интернета;
временную ошибку PLAUD;
исчерпанный Codex limit;
ошибку Microsoft Graph;
reboot Raspberry;
неготовый transcript.
Если AI временно недоступен, запись просто остаётся:
pending_aiЕсли Microsoft Graph не отвечает:
ready_todoВ итоге ошибка на одном из этапов не означает потерю записи: она остаётся в базе и может быть обработана при следующем запуске. Сама база тоже пригодится — например, поверх неё можно сделать свой интерфейс с поиском, фильтрами и просмотром истории обработки.
Дедупликация
Основной ключ:
PLAUD recording_idВ SQLite это PRIMARY KEY.
Кроме того, после успешного создания Microsoft-задачи сохраняется:
todo_idПоэтому повторный запуск pipeline не должен породить ещё одну такую же задачу.
Автоматический запуск
За оркестрацию отвечает небольшой shell-script:
run_pipeline.shОн делает:
1. ingest_plaud.py
2. SELECT COUNT(*)
WHERE status='pending_ai'
3. Если count > 0:
classify.py
иначе:
Codex НЕ запускать
4. SELECT COUNT(*)
WHERE status='ready_todo'
5. Если count > 0:
sync_todo.pyЭто довольно важный момент.
Каждые 5 минут происходит:
PLAUD list_filesНо AI вызывается только тогда, когда действительно есть новые заметки.
systemd
Service обычный oneshot.
Timer:
[Timer]
OnCalendar=*:0/5
Persistent=true
AccuracySec=30sТо есть запуск происходит:
05
10
15
20
...каждого часа.
Что лежит на Raspberry
Примерно такая структура:
/opt/plaud-todo/
├── .venv/
├── config/
│ ├── plaud-todo.env
│ └── msal_cache.json
│
├── data/
│ ├── state.db
│ └── pipeline.lock
│
└── scripts/
├── ingest_plaud.py
├── classify.py
├── sync_todo.py
└── run_pipeline.sh.venv/ — Python-окружение проекта.
Содержит интерпретатор и библиотеки для работы с PLAUD MCP, Microsoft Graph и авторизацией Microsoft. Скрипты запускаются через Python из этого окружения.
config/plaud-todo.env — настройки интеграции.
Здесь указаны идентификатор зарегистрированного Microsoft-приложения, название списка To Do и часовой пояс для сроков и напоминаний.
config/msal_cache.json — кэш авторизации Microsoft.
Используется библиотекой MSAL для получения токенов без ручного входа при каждом запуске. Создаётся при первоначальной авторизации и обновляется по мере работы. Содержит чувствительные данные.
data/state.db — база состояния SQLite.
Хранит записи PLAUD, транскрипты, результаты классификации, категории, ошибки и идентификаторы созданных задач. Статусы показывают, на каком этапе находится каждая запись: ожидает транскрипта, классификации, отправки или уже обработана. Здесь же хранится checkpoint для обнаружения новых записей.
data/pipeline.lock — файл блокировки.
Используется командой flock, чтобы два запуска pipeline не выполнялись одновременно. Создаётся автоматически. Блокировку удерживает работающий процесс.
scripts/ingest_plaud.py — получение записей из PLAUD.
Обращается к PLAUD MCP, находит новые записи и получает их транскрипты. Сначала запрашивает обработанный текст, при необходимости — исходный. Сохраняет данные в SQLite и переводит готовые записи в pending_ai. Аудиофайлы не скачивает.
scripts/classify.py — смысловой разбор заметок.
Берёт из SQLite до 20 ожидающих записей и передаёт их тексты Codex. Модель определяет тип записи, формулирует название задачи, извлекает дату, время и напоминание. Скрипт проверяет JSON-ответ и сохраняет результат. Записи типа task получают статус ready_todo.
scripts/sync_todo.py — создание задач в Microsoft To Do.
Читает готовые результаты, определяет категорию по первому слову транскрипта и нормализует её через точные совпадения, aliases и fuzzy matching. При необходимости создаёт Microsoft category, добавляет её в заголовок и отправляет задачу через Graph. После успешного создания сохраняет todo_id и статус synced.
scripts/run_pipeline.sh — общий сценарий запуска.
Берёт блокировку и последовательно запускает получение записей, классификацию и синхронизацию. Перед классификацией проверяет наличие pending_ai: если очередь пустая, Codex не запускается. Аналогично отправщик запускается только при наличии готовых задач.
Авторизация PLAUD MCP и Codex CLI хранится отдельно, в домашнем каталоге пользователя, от которого работает сервис. Запуск по расписанию обеспечивают
plaud-todo.serviceиplaud-todo.timerв/etc/systemd/system/. Во время классификации также создаётся каталогtmp/для временного JSON-ответа Codex.
Codex тоже запускается не от root.
Безопасность
Несколько вещей, которые я сразу постарался не делать.
Никаких токенов внутри исходников.
Microsoft MSAL cache:
chmod 600PLAUD tokens:
chmod 600Codex auth:
chmod 600Service запускается:
User=one
Group=one
NoNewPrivileges=trueПлюс используется flock, чтобы два экземпляра pipeline случайно не начали одновременно менять SQLite и создавать задачи.
Что получилось в итоге
Теперь интерфейс создания задачи фактически состоит из одной физической кнопки.
Я могу идти по квартире, работать с сервером, паять что-нибудь или собирать робота и просто сказать:
Работа. Завтра провести встречу с Васей в 14:00.
И больше к этой мысли не возвращаться.
Через некоторое время появляется:
Работа: Завтра провести встречу с Васей в 14:00.с правильной датой и категорией.
Или:
Хобби. На выходных распечатать крепление для гусенечного манипулятора.
Получаю:
Хобби: На выходных распечатать крепление для гусенечного манипулятора.Или вообще без категории:
Купить батарейки.
Получаю обычную:
Купить батарейкиАнализ задач: отчеты за неделю
У этой связки есть ещё одно применение: задачи можно использовать для подготовки коротких отчётов.
За неделю в Microsoft To Do накапливается история: что планировал, что закрыл, что осталось висеть. Если передать эти данные в Codex или чат GPT — через подключённый инструмент либо обычную выгрузку, — можно получить готовый дайджест.
Например, попросить:
Сделай сводку по моим рабочим задачам за неделю: что завершено, что осталось в работе и что запланировано дальше. Сгруппируй по темам, убери повторы. Отдельно выдели блокеры, если они указаны в заметках. Подготовь короткую версию для дейли, которую можно рассказать за минуту.
Для ежедневного дейли достаточно поменять период: что сделал вчера, что собираюсь делать сегодня и где нужна помощь. Для недельного отчёта — собрать общую картину по проектам.
Здесь пригодится то, что в задачах сохраняется исходная транскрипция. Короткий заголовок может звучать как «Проверить резервные копии», а в заметке останутся детали: какой сервер, что именно проверить и зачем.
Для этого на этапе тестирования я поднял на своём ПК локальный MCP-сервер для взаимодействия Codex с Microsoft To Do, основанный на пакете @mag-cie/mcp-microsoft-todo. Через него Codex получает задачи напрямую, поэтому можно прямо в чате попросить проанализировать выполненные дела, собрать дайджест за неделю или подготовить короткое саммари для дейли. В перспективе, планирую перенести этот функционал на Rasberry Pi, с созданием автоматических отчетов в OneNote.
Что ещё хочется добавить
Сейчас система уже делает то, ради чего создавалась, но направлений развития хватает.
Например:
обработку длинных встреч отдельно от коротких заметок для записи в протокола в OneNote;
автоматические weekly/daily summaries;
веб-интерфейс для SQLite - свой таскер с нужными фильтрами. To Do не идеален;
автоматическое обучение списка alias по повторяющимся ошибкам PLAUD.
Что пошло не так: аутентификация
На бумаге схема выглядела просто: авторизовать PLAUD, авторизовать Codex, получить токен Microsoft Graph — и можно писать интеграцию. На практике именно аутентификация оказалась одной из неприятных моментов проекта, в основном потому, что Raspberry Pi работает headless, а OAuth-потоки разных сервисов предполагают наличие браузера на той же машине.
PLAUD OAuth и localhost, который оказался не тем localhost
PLAUD MCP устанавливался командой:
npx -y @plaud-ai/mcp@latest installИнсталлятор нашёл конфигурацию Codex:
Codex Desktop /home/one/.codex/config.tomlи запустил OAuth.
Проблема появилась на callback. PLAUD использовал адрес:
http://localhost:8199/auth/callbackНо браузер у меня был открыт на Windows, а сам OAuth listener работал на Raspberry Pi.
В результате после авторизации браузер пытался открыть:
localhost:8199на моём Windows-компе, а не на Raspberry. PLAUD MCP на Pi callback не получал, и OAuth завершался по timeout.
Решение — SSH port forwarding:
ssh -F NUL -L 8199:127.0.0.1:8199 one@10.1.9.149Цепочка стала выглядеть так:
Browser Windows
↓
localhost:8199
↓ SSH tunnel
Raspberry Pi:8199
↓
PLAUD MCP OAuth listenerOAuth-токены PLAUD сохранились локально.
Codex: Device Code внезапно потребовал номер телефона
Следующей проблемой оказался сам Codex.
Я хотел использовать авторизацию через ChatGPT, а не OpenAI API key. Это было принципиально: API key означал бы отдельный usage-based API billing, тогда как Codex через ChatGPT использует лимиты моего ChatGPT workspace.
При попытке пройти стандартную headless-авторизацию через Device Code браузер в какой-то момент отправил меня на:
auth.openai.com/add-phoneи потребовал добавить номер телефона.
Проблема была в том, что нужной страны в списке не оказалось.
При этом на Windows у меня уже был нормально авторизован Codex Desktop под тем же ChatGPT workspace. Поэтому я использовал существующую авторизацию Codex на Raspberry и перенёс файл авторизации в:
/home/one/.codex/auth.jsonПосле этого ограничил права:
chmod 600 ~/.codex/auth.jsonПроверка:
codex login statusдала:
Logged in using ChatGPTТо есть Raspberry стал использовать именно ChatGPT-аутентификацию, без API key.
Microsoft To Do: личный аккаунт потребовал немного другой OAuth-схемы
Microsoft To Do у меня находится на личном Microsoft Account, а не в корпоративном Entra tenant.
Поэтому App Registration создавался с поддержкой personal Microsoft accounts, а в MSAL использовался authority:
https://login.microsoftonline.com/consumersПриложение работает как public client:
Allow public client flows = YesClient Secret здесь вообще не нужен.
Первоначальное Graph permission:
Tasks.ReadWriteПозже, когда я добавил автоматическое создание категорий, понадобилось ещё:
MailboxSettings.ReadWriteДля Raspberry Device Code Flow оказался здесь удобным. Скрипт выводил что-то вроде:
To sign in, use a web browser to open the page
https://www.microsoft.com/link
and enter the code XXXXXXXXОткрыл страницу на ПК, ввел код, а Raspberry получал токен.
После этого повторный login уже не требуется — MSAL пытается обновлять access token из cache автоматически.
В итоге получилось три независимых механизма аутентификации
PLAUD
→ OAuth token
→ ~/.plaud/tokens-mcp.json
Codex
→ ChatGPT auth
→ ~/.codex/auth.json
Microsoft
→ OAuth/MSAL
→ /opt/plaud-todo/config/msal_cache.jsonИ systemd service обязательно запускается от пользователя:
User=one
Group=oneа не от root.
Иначе сервис просто не увидит пользовательские PLAUD/Codex credentials.
Все чувствительные файлы в итоге получили права 600.
Главный вывод
Самой полезной частью этого проекта для меня оказался не сам AI.
Первая версия была буквально:
дать агенту все инструменты
и попросить сделать всёРаботало.
Но оказалось дорого и избыточно.
Финальная архитектура наоборот максимально скучная:
Python делает детерминированные вещи.
SQLite хранит состояние.
LLM используется только там, где действительно требуется понимание языка.Именно после этого система стала похожа не на демонстрацию возможностей нейросети, а на нормальный постоянно работающий сервис.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.